--- # === IDENTITÉ === module: M1 nom: "Répertoire clients" ecran: repertoire-clients owner_spec: Matthieu backup_spec: Tristan version: V0 date_redaction: 2026-05-28 # === LIENS === spec_front: ./spec-front.md maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898" # === LIEN LESSTIME === lesstime_taskgroup_id: 23 lesstime_project_id: 6 statut_global: en_dev # === DÉPENDANCES AMONT === depend_de: - M0-categories # Category + CategoryType déjà mergés sur develop - Sites # SitesModule + 3 sites seedés (86 / 17 / 82) déjà en place - Core # User, Role, Permission, Audit, JWT déjà en place - Shared # TimestampableBlamableTrait + Subscriber (ERP-52) --- # Spec back — Module 1 : Répertoire clients ## 1. Contexte Cette spec **complète et précise** la [spec front V0](./spec-front.md) (`M1-reportoire-clients.docx` du 22/05/2026) avec tout ce qui touche au back : décisions d'archi, modèle de données, migrations, API REST, RBAC, règles de gestion, tests, hors-périmètre. **Module cible** : extension du module `Commercial` existant (`src/Module/Commercial/`). L'objectif est de fournir la première sous-section métier du Commercial (Clients), avec un pattern réutilisable plus tard pour Suppliers / Prestas. **Dépendances déjà en place sur `develop`** : - `Catalog` (M0) → `Category` + `CategoryType` (sera étendu par seed M1 : `DISTRIBUTEUR`, `COURTIER`, `SECTEUR`, `AUTRE`) - `Sites` → 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82) - `Shared` → `TimestampableBlamableTrait` + `Subscriber` (ERP-52) - `Core` → User, Role, Permission, Audit ## 2. Décisions d'archi ### 2.1 Module — Extension de `Commercial`, pas un module dédié Le client M1 vit sous `src/Module/Commercial/` (déjà existant et déclaré). Pas de nouveau module `Clients`. Rationale : - Cohérence avec le pattern MALIO : Commercial = couche **Tiers** (Clients + Fournisseurs + Prestataires). Le module existant porte déjà `Suppliers`. - Évite la prolifération de modules micro-scope. - Si le scope explose à terme, on extrait via le pattern `Shared/Domain/Contract/`. Le `CommercialModule.php` actuel est quasi vide — on l'enrichit avec la méthode statique `permissions()` (aujourd'hui absente) : ```php */ public static function permissions(): array { return [ ['code' => 'commercial.clients.view', 'label' => 'Voir les clients'], ['code' => 'commercial.clients.manage', 'label' => 'Créer / modifier les clients (hors onglet Comptabilité)'], ['code' => 'commercial.clients.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un client'], ['code' => 'commercial.clients.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un client'], ['code' => 'commercial.clients.archive', 'label' => 'Archiver / restaurer un client'], // Note : la permission Suppliers n'est pas ajoutee ici (deja // gere par un autre ticket / a etendre quand le M-Fournisseurs // sera spec). ]; } } ``` ### 2.2 IDs entier auto-increment Postgres natif Cohérent avec le M0 et l'ensemble Starseed. Pas d'UUID, pas de ULID. ### 2.3 Soft delete vs Archive — **deux mécanismes distincts** **Décision validée par Tristan le 28/05 (s'écarte du pattern M0)** : | Mécanisme | Colonne | Visibilité par défaut | Restauration | Utilisateur | |---|---|---|---|---| | **Archive** (fonctionnel) | `is_archived` (bool, default false) | masqué | Oui (toggle UI) | Admin via permission `commercial.clients.archive` | | **Soft delete** (technique) | `deleted_at` (timestamp nullable) | masqué | HP M2+ | Aucun rôle au M1 (HP) | Le M1 expose **uniquement le mécanisme Archive**. Le soft delete reste préparé en BDD (la colonne existe sur toutes les nouvelles entités Tiers via `TimestampableBlamableTrait` étendu — cf. § 2.6) mais n'est **pas exposé** via API au M1. **Conséquences** : - Le `DELETE /api/clients/{id}` n'est **pas exposé** au M1 (404 si appelé). Activé en HP M2. - `GET /api/clients?includeArchived=true` permet de voir les archivés (permission requise : `commercial.clients.view`). - PATCH `{ "isArchived": true }` archive ; PATCH `{ "isArchived": false }` restaure. - Les unicités métier (SIREN, nom, email) ignorent les archivés ET les soft-deletés (cf. § 3.5). ### 2.4 Unicité partielle Postgres — 1 contrainte sur Client > **Décision Q4 (29/05/2026, Matthieu)** : l'unicité métier porte **uniquement sur le nom de société**. Le SIREN et l'email principal ne sont **pas** uniques (un même email peut servir plusieurs clients ; un SIREN peut être partagé entre établissements). Index unique partiel (`WHERE is_archived = false AND deleted_at IS NULL`) sur : 1. `LOWER(company_name)` — unicité du nom d'entreprise (case-insensitive, parmi non-archivés et non soft-deletés) Tentative de doublon → `409 Conflict` géré par le `ClientProcessor` qui attrape la `UniqueConstraintViolationException`. ### 2.5 Audit & traces temporelles Pattern Starseed standard : - `#[Auditable]` sur `Client`, `ClientContact`, `ClientAddress`, `ClientRib` - **Tous les champs sont auditables** (pas d'`#[AuditIgnore]`) — y compris `ClientRib.iban` et `ClientRib.bic`. Décision validée par Matthieu en revue de MR (29/05/2026) : l'audit étant **admin-only** côté Starseed, l'équipe a besoin de tracer les modifications RIB pour le suivi comptable et la conformité. - Audit Many-to-Many automatique pour la collection `client.categories` (cf. doc audit-log) ### 2.6 Timestampable + Blamable Toutes les entités métier nouvelles implémentent `TimestampableInterface` + `BlamableInterface` et utilisent `TimestampableBlamableTrait`. Concerne : `Client`, `ClientContact`, `ClientAddress`, `ClientRib`. Pas applicable aux référentiels statiques `TvaMode`, `PaymentDelay`, `PaymentType`, `Bank` (whitelistés dans `EntitiesAreTimestampableBlamableTest::EXCLUDED`). ### 2.7 Permissions RBAC — granularité 5 permissions au lieu des 2 standards (view + manage) : | Permission | Admin | Bureau | Compta | Commerciale | Usine | |---|---|---|---|---|---| | `commercial.clients.view` | ✅ | ✅ | ✅ | ✅ | ❌ | | `commercial.clients.manage` | ✅ | ✅ | ❌ | ✅ | ❌ | | `commercial.clients.accounting.view` | ✅ | ❌ | ✅ | ❌ | ❌ | | `commercial.clients.accounting.manage` | ✅ | ❌ | ✅ | ❌ | ❌ | | `commercial.clients.archive` | ✅ | ❌ | ❌ | ❌ | ❌ | **Notes** : - **Compta peut éditer l'onglet Comptabilité** (`accounting.manage`) d'un client existant — décision revue MR Matthieu 29/05, aligné avec le docx d'origine. Compta **ne peut pas créer** un client (pas de `manage` global) ni modifier les autres onglets. - Commerciale a `view` global mais **n'a pas** `accounting.view` → l'onglet Comptabilité est masqué pour ce rôle. Le filtre se fait à 2 niveaux : (a) API Platform `security` sur les opérations qui exposent les groupes `client:accounting:*` ; (b) front masque l'onglet via `usePermissions().has('commercial.clients.accounting.view')`. ### 2.8 Validation incrémentale par onglet (workflow front-driven) Le `Client` est créé en BDD **dès validation du formulaire principal** via POST `/api/clients`. Les onglets suivants déclenchent des **PATCH partiels** avec des groupes de sérialisation dédiés : - `client:write:main` — formulaire principal (POST + PATCH) - `client:write:information` — onglet Information - `client:write:contacts` — onglet Contact (PUT/POST/PATCH sur `client_contact` via la sous-ressource) - `client:write:addresses` — onglet Adresse - `client:write:accounting` — onglet Comptabilité (security separée) **Pas de state machine côté back** (pas de `status = draft|active`). Le client est immédiatement actif dès POST réussi. Si l'utilisateur quitte avant de compléter, le client existe avec ses données minimales (formulaire principal uniquement). C'est la responsabilité du front d'**enchaîner les PATCHs** dans l'ordre de la séquence. ### 2.9 Normalisation serveur des entrées texte Centralisée dans un `ClientNormalizer` (service interne, appelé par `ClientProcessor` et `ClientContactProcessor` avant validation) : ```php final class ClientFieldNormalizer { public function normalizeCompanyName(?string $value): ?string { return $value === null ? null : mb_strtoupper(trim($value), 'UTF-8'); } public function normalizePersonName(?string $value): ?string { if ($value === null) { return null; } $value = trim($value); return $value === '' ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8'); } public function normalizeEmail(?string $value): ?string { return $value === null ? null : mb_strtolower(trim($value), 'UTF-8'); } public function normalizePhone(?string $value): ?string { // Persistance : chiffres uniquement (10 attendus pour FR). return $value === null ? null : preg_replace('/\D+/', '', $value); } } ``` Le **formatage `XX XX XX XX XX`** est fait à l'affichage côté front (filter Vue `formatPhoneFR()`). Le back stocke `0612345678` (10 chiffres). ## 3. Modèle de données ### 3.1 Diagramme ``` +-------------------+ +-----------------------+ +--------------+ | client |--n:m-->| client_category |<--n:m--| category | | | +-----------------------+ | (Catalog) | | id (PK) | +--------------+ | company_name | | first_name | +-----------------------+ +--------------+ | last_name |--1:n-->| client_contact | | site | | phone_primary | +-----------------------+ | (Sites) | | phone_secondary | +--------------+ | email | +-----------------------+ ^ | distributor_id |--1:n-->| client_address |--n:m---------+ | broker_id | +-----------------------+ | triage_service | | | is_archived | +--n:m--+--> client_contact | deleted_at | | | description | +-----------------------+ +--------------+ | competitors |--1:n-->| client_rib | | tva_mode | | founded_at | +-----------------------+ | payment_* | | employees_count | | bank | | revenue_amount | siren (Oui) +--------------+ | director_name | account_number (Oui) | profit_amount | tva_mode_id (FK) +-------------------+ n_tva ^ ^ payment_delay_id (FK) | | payment_type_id (FK) | +-- distributor_id bank_id (FK nullable) +---- broker_id (auto-references via FK Client) ``` **Particularités** : - `client.distributor_id` et `client.broker_id` sont **2 FK auto-référentes** vers `client.id`. Une seule est non-NULL à la fois (RG-1.03). - `client_address` est rattachée à `n` `site` (M2M via `client_address_site`) et à `n` `client_contact` (M2M via `client_address_contact`). - `client_rib` appartient à un `Client` (1:n). - Les colonnes Information (description, competitors, founded_at, employees_count, revenue_amount, director_name, profit_amount) sont **sur Client lui-même** (1:1 conceptuel — pas d'entité Information séparée). ### 3.2 Migration Doctrine — SQL Postgres Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/Version20260601000000.php` (à dater par le dev). > **Décision 29/05/2026 (vérifiée empiriquement en dev)** : cette migration crée un **schéma avec FK cross-module** (`user`, `category`, `site`) → elle a la même dépendance d'ordre que les migrations d'init. Le namespace modulaire `App\Module\Commercial\…` casse `make db-reset` : Doctrine Migrations 3.x trie par **FQCN alphabétique** (`App\…` < `DoctrineMigrations\…`), donc la migration client tournerait AVANT `user`/`category`/`site` et ses FK échoueraient. Elle relève donc de l'**exception racine** de la règle ABSOLUE n°11 (même choix que la migration cross-module ERP-67). Le namespace modulaire reste réservé aux évolutions post-schéma (ajout de colonnes/index). La correction long-terme (MigrationsComparator custom, tri par timestamp) est un ticket archi dédié, hors scope M1. ```sql -- ===================================================================== -- Référentiels comptables (4 tables statiques seedées par le M1) -- ===================================================================== CREATE TABLE tva_mode ( id SERIAL PRIMARY KEY, code VARCHAR(30) NOT NULL UNIQUE, label VARCHAR(120) NOT NULL, position INT NOT NULL DEFAULT 0 ); CREATE TABLE payment_delay ( id SERIAL PRIMARY KEY, code VARCHAR(30) NOT NULL UNIQUE, label VARCHAR(120) NOT NULL, position INT NOT NULL DEFAULT 0 ); CREATE TABLE payment_type ( id SERIAL PRIMARY KEY, code VARCHAR(30) NOT NULL UNIQUE, label VARCHAR(120) NOT NULL, position INT NOT NULL DEFAULT 0 ); CREATE TABLE bank ( id SERIAL PRIMARY KEY, code VARCHAR(30) NOT NULL UNIQUE, label VARCHAR(120) NOT NULL, position INT NOT NULL DEFAULT 0 ); -- Seed M1 — référentiels comptables (cf. décision Tristan 28/05). INSERT INTO tva_mode (code, label, position) VALUES ('FRANCE_VENTES', 'France (ventes)', 10), ('EXPORT_VENTES', 'Export (ventes)', 20), ('INTRACOM_VENTES', 'Intracom (ventes)', 30); INSERT INTO payment_delay (code, label, position) VALUES ('J15', '15 jours', 10), ('J30', '30 jours', 20), ('A_RECEPTION', 'À réception', 30); INSERT INTO payment_type (code, label, position) VALUES ('VIREMENT', 'Virement', 10), ('LCR', 'LCR', 20), ('NON_SOUMISE', 'Non soumise', 30), ('CHEQUE', 'Chèque', 40); INSERT INTO bank (code, label, position) VALUES ('SG', 'Société Générale', 10), ('CIC', 'CIC', 20), ('CA', 'Crédit Agricole', 30); -- ===================================================================== -- Table principale `client` -- ===================================================================== CREATE TABLE client ( id SERIAL PRIMARY KEY, -- Formulaire principal company_name VARCHAR(180) NOT NULL, first_name VARCHAR(120), last_name VARCHAR(120), phone_primary VARCHAR(20) NOT NULL, phone_secondary VARCHAR(20), email VARCHAR(180) NOT NULL, distributor_id INT REFERENCES client(id) ON DELETE SET NULL, broker_id INT REFERENCES client(id) ON DELETE SET NULL, triage_service BOOLEAN NOT NULL DEFAULT FALSE, -- Onglet Information (Commerciale obligatoire — RG-1.04 — 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), -- Onglet Comptabilité — Admin seul 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 (mécanisme exposé M1) is_archived BOOLEAN NOT NULL DEFAULT FALSE, archived_at TIMESTAMPTZ, -- Soft delete (préparé, non exposé au M1) deleted_at TIMESTAMPTZ, -- Timestampable + Blamable (cf. Shared) 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, -- Une seule des deux FK distributor/broker doit être non-null (RG-1.03) CONSTRAINT chk_client_distrib_or_broker CHECK (NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)) ); CREATE INDEX idx_client_is_archived ON client(is_archived); CREATE INDEX idx_client_deleted_at ON client(deleted_at); CREATE INDEX idx_client_distributor_id ON client(distributor_id); CREATE INDEX idx_client_broker_id ON client(broker_id); CREATE INDEX idx_client_created_by ON client(created_by); CREATE INDEX idx_client_updated_by ON client(updated_by); -- Unicité métier (partielle : on ignore archives + soft-delete) -- Décision Q4 (29/05/2026) : unicité sur le nom de société UNIQUEMENT. -- SIREN et email NE SONT PAS uniques (pas d'index uq_client_siren_active ni uq_client_email_active). CREATE UNIQUE INDEX uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL; -- ===================================================================== -- Jointure M2M client ↔ category -- ===================================================================== CREATE TABLE client_category ( client_id INT NOT NULL REFERENCES client(id) ON DELETE CASCADE, category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT, PRIMARY KEY (client_id, category_id) ); CREATE INDEX idx_client_category_category ON client_category(category_id); -- ===================================================================== -- Sous-collection : Contacts du client (1:n) -- ===================================================================== CREATE TABLE client_contact ( id SERIAL PRIMARY KEY, client_id INT NOT NULL REFERENCES client(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-1.05 : au moins Nom OU Prénom CONSTRAINT chk_client_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL) ); CREATE INDEX idx_client_contact_client ON client_contact(client_id); -- ===================================================================== -- Sous-collection : Adresses du client (1:n) -- ===================================================================== CREATE TABLE client_address ( id SERIAL PRIMARY KEY, client_id INT NOT NULL REFERENCES client(id) ON DELETE CASCADE, is_prospect BOOLEAN NOT NULL DEFAULT FALSE, is_delivery BOOLEAN NOT NULL DEFAULT FALSE, is_billing BOOLEAN NOT NULL DEFAULT FALSE, 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), billing_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-1.06/07/08 : exclusivité prospect vs (livraison ou facturation) CONSTRAINT chk_client_address_prospect_exclusive CHECK (NOT (is_prospect = TRUE AND (is_delivery = TRUE OR is_billing = TRUE))), -- RG-1.11 : billing_email obligatoire ssi is_billing = TRUE CONSTRAINT chk_client_address_billing_email CHECK ((is_billing = FALSE AND billing_email IS NULL) OR (is_billing = TRUE AND billing_email IS NOT NULL)) ); CREATE INDEX idx_client_address_client ON client_address(client_id); -- M2M client_address ↔ site (RG-1.10 : ≥ 1 site obligatoire) CREATE TABLE client_address_site ( client_address_id INT NOT NULL REFERENCES client_address(id) ON DELETE CASCADE, site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT, PRIMARY KEY (client_address_id, site_id) ); -- M2M client_address ↔ client_contact CREATE TABLE client_address_contact ( client_address_id INT NOT NULL REFERENCES client_address(id) ON DELETE CASCADE, client_contact_id INT NOT NULL REFERENCES client_contact(id) ON DELETE CASCADE, PRIMARY KEY (client_address_id, client_contact_id) ); -- M2M client_address ↔ category (catégorie d'adresse) CREATE TABLE client_address_category ( client_address_id INT NOT NULL REFERENCES client_address(id) ON DELETE CASCADE, category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT, PRIMARY KEY (client_address_id, category_id) ); -- ===================================================================== -- Sous-collection : RIB du client (1:n) -- ===================================================================== CREATE TABLE client_rib ( id SERIAL PRIMARY KEY, client_id INT NOT NULL REFERENCES client(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_client_rib_client ON client_rib(client_id); ``` ### 3.3 Seed `CategoryType` (extension du M0) Au M0, la table `category_type` a été créée mais reste vide (HP-1 du M0). Le M1 lève cette restriction avec un seed initial des **types métier** dont le module Tiers a besoin : ```sql INSERT INTO category_type (code, label, position) VALUES ('DISTRIBUTEUR', 'Distributeur', 10), ('COURTIER', 'Courtier', 20), ('SECTEUR', 'Secteur', 30), ('AUTRE', 'Autre', 99); ``` > **Note** : le CRUD admin de `CategoryType` reste HP (cf. M0). > > **Seed en DEUX endroits (décision 29/05, vérifiée empiriquement)** : le `make db-reset` lance les fixtures, dont le purger Doctrine **vide `category_type`** (entité M0 mappée) avant `load()` → un seed posé uniquement en migration disparaît en dev/test. Donc : > 1. **Migration** (`ON CONFLICT (code) DO NOTHING`) → sert en **prod** (pas de fixtures). > 2. **Fixture Commercial idempotente** (ex. `CommercialReferentialFixtures`) re-seedant les 4 types → survit au `db-reset`, satisfait le critère « 4 types présents après db-reset ». > > ⚠ **À venir en ERP-54** : `tva_mode` / `payment_delay` / `payment_type` / `bank` ne sont pas encore des entités mappées au M1.0 → le purger ne les touche pas, leur seed migration survit. **Dès qu'ERP-54 crée leurs entités, ils seront purgés au db-reset** → il faudra les ajouter à la même fixture référentielle. > 🔗 **Coordination ERP-68** : ERP-53 pose la fixture référentielle minimale (4 category_types). ERP-68 l'**étend** (clients de démo, ~12-15) sans la dupliquer. ### 3.4 Entité `Client` — squelette ```php ['client:read', 'default:read']], provider: ClientProvider::class, ), new Get( security: "is_granted('commercial.clients.view')", normalizationContext: ['groups' => ['client:read', 'client:read:accounting', 'default:read']], // L'objet retourne contient TOUS les groupes ; le filtre des // champs comptables se fait au niveau Provider en fonction de // is_granted('commercial.clients.accounting.view'). provider: ClientProvider::class, ), new Post( security: "is_granted('commercial.clients.manage')", normalizationContext: ['groups' => ['client:read', 'default:read']], denormalizationContext: ['groups' => ['client:write:main']], processor: ClientProcessor::class, ), new Patch( security: "is_granted('commercial.clients.manage')", // Le ClientProcessor inspecte les groupes effectivement envoyes // pour autoriser/refuser onglet par onglet (cf. § 2.8 + § 5). // Patch sur les champs comptables exige is_granted(accounting.manage). normalizationContext: ['groups' => ['client:read', 'default:read']], denormalizationContext: ['groups' => [ 'client:write:main', 'client:write:information', 'client:write:accounting', ]], provider: ClientProvider::class, processor: ClientProcessor::class, ), // Pas de Delete au M1 (HP M2). L'archivage est fait via PATCH // { isArchived: true } gere par le ClientProcessor avec security // additionnelle is_granted('commercial.clients.archive'). ], )] #[ORM\Entity(repositoryClass: DoctrineClientRepository::class)] #[ORM\Table(name: 'client')] #[Auditable] class Client implements TimestampableInterface, BlamableInterface { use TimestampableBlamableTrait; #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['client:read'])] private ?int $id = null; #[ORM\Column(length: 180)] #[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')] #[Assert\Length(min: 2, max: 180, normalizer: 'trim')] #[Groups(['client:read', 'client:write:main'])] private ?string $companyName = null; // RG-1.01 — first_name OU last_name obligatoire (validation Assert\Callback // au niveau de l'entite, levee dans le Processor). #[ORM\Column(length: 120, nullable: true)] #[Assert\Length(max: 120, normalizer: 'trim')] #[Groups(['client:read', 'client:write:main'])] private ?string $firstName = null; #[ORM\Column(length: 120, nullable: true)] #[Assert\Length(max: 120, normalizer: 'trim')] #[Groups(['client:read', 'client:write:main'])] private ?string $lastName = null; #[ORM\Column(length: 20)] #[Assert\NotBlank] #[Groups(['client:read', 'client:write:main'])] private ?string $phonePrimary = null; #[ORM\Column(length: 20, nullable: true)] #[Groups(['client:read', 'client:write:main'])] private ?string $phoneSecondary = null; #[ORM\Column(length: 180)] #[Assert\NotBlank] #[Assert\Email] #[Groups(['client:read', 'client:write:main'])] private ?string $email = null; // RG-1.03 — distributor / broker auto-references (mutuellement exclusives, // contrainte CHECK en base). #[ORM\ManyToOne(targetEntity: Client::class)] #[ORM\JoinColumn(name: 'distributor_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] #[Groups(['client:read', 'client:write:main'])] private ?Client $distributor = null; #[ORM\ManyToOne(targetEntity: Client::class)] #[ORM\JoinColumn(name: 'broker_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] #[Groups(['client:read', 'client:write:main'])] private ?Client $broker = null; #[ORM\Column(name: 'triage_service', options: ['default' => false])] #[Groups(['client:read', 'client:write:main'])] private bool $triageService = false; /** @var Collection */ #[ORM\ManyToMany(targetEntity: Category::class)] #[ORM\JoinTable(name: 'client_category')] #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')] #[Groups(['client:read', 'client:write:main'])] private Collection $categories; // === Onglet Information === #[ORM\Column(type: 'text', nullable: true)] #[Groups(['client:read', 'client:write:information'])] private ?string $description = null; #[ORM\Column(length: 255, nullable: true)] #[Groups(['client:read', 'client:write:information'])] private ?string $competitors = null; #[ORM\Column(type: 'date_immutable', nullable: true)] #[Groups(['client:read', 'client:write:information'])] private ?DateTimeImmutable $foundedAt = null; #[ORM\Column(nullable: true)] #[Assert\PositiveOrZero] #[Groups(['client:read', 'client:write:information'])] private ?int $employeesCount = null; #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] #[Groups(['client:read', 'client:write:information'])] private ?string $revenueAmount = null; #[ORM\Column(length: 120, nullable: true)] #[Groups(['client:read', 'client:write:information'])] private ?string $directorName = null; #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] #[Groups(['client:read', 'client:write:information'])] private ?string $profitAmount = null; // === Onglet Comptabilité === // Lecture conditionnee via group 'client:read:accounting' (le Provider // l'ajoute dynamiquement si l'user a la permission accounting.view). // Ecriture conditionnee via group 'client:write:accounting' (le Processor // exige is_granted('commercial.clients.accounting.manage') si ce groupe // est present dans le payload). #[ORM\Column(length: 20, nullable: true)] #[Groups(['client:read:accounting', 'client:write:accounting'])] private ?string $siren = null; #[ORM\Column(length: 40, nullable: true)] #[Groups(['client:read:accounting', 'client:write:accounting'])] private ?string $accountNumber = null; #[ORM\ManyToOne(targetEntity: TvaMode::class)] #[ORM\JoinColumn(name: 'tva_mode_id', nullable: true, onDelete: 'RESTRICT')] #[Groups(['client:read:accounting', 'client:write:accounting'])] private ?TvaMode $tvaMode = null; #[ORM\Column(length: 40, nullable: true)] #[Groups(['client:read:accounting', 'client:write:accounting'])] private ?string $nTva = null; #[ORM\ManyToOne(targetEntity: PaymentDelay::class)] #[ORM\JoinColumn(name: 'payment_delay_id', nullable: true, onDelete: 'RESTRICT')] #[Groups(['client:read:accounting', 'client:write:accounting'])] private ?PaymentDelay $paymentDelay = null; #[ORM\ManyToOne(targetEntity: PaymentType::class)] #[ORM\JoinColumn(name: 'payment_type_id', nullable: true, onDelete: 'RESTRICT')] #[Groups(['client:read:accounting', 'client:write:accounting'])] private ?PaymentType $paymentType = null; #[ORM\ManyToOne(targetEntity: Bank::class)] #[ORM\JoinColumn(name: 'bank_id', nullable: true, onDelete: 'RESTRICT')] #[Groups(['client:read:accounting', 'client:write:accounting'])] private ?Bank $bank = null; // === Sous-collections (exposées via sous-ressources API Platform dédiées) === /** @var Collection */ #[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $contacts; /** @var Collection */ #[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $addresses; /** @var Collection */ #[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $ribs; // === Archive / Soft delete === #[ORM\Column(name: 'is_archived', options: ['default' => false])] #[Groups(['client:read', 'client:write:archive'])] private bool $isArchived = false; #[ORM\Column(type: 'datetime_immutable', nullable: true)] #[Groups(['client:read'])] private ?DateTimeImmutable $archivedAt = null; #[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 pour la lisibilite — pattern Starseed standard. } ``` ### 3.5 Squelettes des autres entités **`ClientContact`**, **`ClientAddress`**, **`ClientRib`** : même pattern (`#[Auditable]`, `TimestampableBlamableTrait`, FK `client_id`). ⚠ **Aucun `#[AuditIgnore]`** sur `ClientRib.iban`/`bic` — tous les champs RIB sont audités (décision Matthieu en revue MR du 29/05/2026, cf. § 2.5 : audit admin-only → traçabilité comptable nécessaire). Source de vérité : § 2.5. **Référentiels (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`)** : entités lecture seule via API Platform `GetCollection` + `Get` uniquement (security `commercial.clients.view`). Pas de POST/PATCH/DELETE au M1 (HP). Pas de Timestampable+Blamable (whitelistés dans `EntitiesAreTimestampableBlamableTest::EXCLUDED`). ## 4. API REST (API Platform) ### 4.1 `GET /api/clients` — Liste - **Security** : `is_granted('commercial.clients.view')` - **Query params** : - `includeArchived=true|false` (default `false`) - `categoryType=` (filtre par type de catégorie via `SearchFilter`) - `search=` (recherche fuzzy sur companyName + lastName + email) - **Tri par défaut** : `companyName ASC` - **Pagination** : front via `` (volumétrie cible faible). Pas de pagination serveur au M1. - **Réponse 200** (JSON-LD Hydra) : items avec champs `client:read` UNIQUEMENT (pas les champs `client:read:accounting` sauf si l'user a la permission `accounting.view`). - **Codes** : `200` / `401` / `403` ### 4.2 `GET /api/clients/{id}` — Détail - **Security** : `is_granted('commercial.clients.view')` - **Comportement** : retourne le client + ses contacts + ses adresses + ses RIBs. Les champs `client:read:accounting` sont inclus uniquement si l'user a la permission `commercial.clients.accounting.view`. - **Codes** : `200` / `404` / `401` / `403` ### 4.3 `POST /api/clients` — Création (formulaire principal) - **Security** : `is_granted('commercial.clients.manage')` - **Body** (groupe `client:write:main` uniquement) : ```json { "companyName": "ACME SAS", "firstName": "Jean", "lastName": "Dupont", "phonePrimary": "0612345678", "email": "jean.dupont@acme.fr", "categories": ["/api/categories/3", "/api/categories/7"], "distributor": null, "broker": null, "triageService": false } ``` - **Réponse 201** : le client créé avec son `id`. Le front enchaîne ensuite les PATCH par onglet. - **Codes** : - `201` / `400` / `401` / `403` - `409 Conflict` si doublon de nom de société (`companyName` — RG-1.16). SIREN et email ne sont pas uniques (cf. Q4, § 2.4). - `422 Unprocessable Entity` : - RG-1.01 : ni firstName ni lastName - RG-1.03 : distributor + broker remplis simultanément - Catégories vides (Assert\Count min=1) ### 4.4 `PATCH /api/clients/{id}` — Modification - **Security base** : `is_granted('commercial.clients.manage')` - **Security additionnelle** (dans le `ClientProcessor`) : - Si le payload contient un champ du groupe `client:write:accounting` → exige `is_granted('commercial.clients.accounting.manage')` - Si le payload contient `isArchived` → exige `is_granted('commercial.clients.archive')` - **Body** : merge-patch+json. Le client envoie uniquement les champs modifiés. - **Réponse 200** : le client mis à jour. - **Codes** : `200` / `400` / `401` / `403` / `404` / `409` / `422` ### 4.5 Sous-ressources **Contacts** : `POST /api/clients/{id}/contacts`, `PATCH /api/client_contacts/{id}`, `DELETE /api/client_contacts/{id}` (DELETE physique au M1 — c'est une sous-collection, pas le client lui-même). - **Security** : `is_granted('commercial.clients.manage')` - **RG-1.14** : `POST` du dernier contact ne peut pas vider la collection — au moins 1 contact reste obligatoire si le client a déjà été créé avec un onglet Contact validé. Géré dans le `ClientContactProcessor`. → 409 si tentative. **Adresses** : `POST /api/clients/{id}/addresses`, `PATCH /api/client_addresses/{id}`, `DELETE /api/client_addresses/{id}`. - **Security** : `is_granted('commercial.clients.manage')` **RIBs** : `POST /api/clients/{id}/ribs`, `PATCH /api/client_ribs/{id}`, `DELETE /api/client_ribs/{id}`. - **Security** : `is_granted('commercial.clients.accounting.manage')` - **RG-1.13** : si le client a `paymentType.code = LCR`, suppression du dernier RIB → 409. ### 4.6 `GET /api/clients/export.xlsx` — Export - **Security** : `is_granted('commercial.clients.view')` - **Comportement** : génère un fichier XLSX contenant les clients **non archivés** par défaut (mêmes filtres que `GET /api/clients`). - Colonnes : Nom entreprise, Nom contact principal, 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. - **Implémentation** : controller custom `ClientExportController` avec `#[Route(priority: 1)]` (cf. CLAUDE.md règle ABSOLUE — éviter conflit API Platform). Lib : PhpSpreadsheet. - **Réponse 200** : `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, `Content-Disposition: attachment; filename="repertoire-clients-{YYYYMMDD}.xlsx"` ### 4.7 Référentiels - `GET /api/tva_modes` — lecture seule, security `is_granted('commercial.clients.view')` - `GET /api/payment_delays` — idem - `GET /api/payment_types` — idem - `GET /api/banks` — idem Tri par `position ASC` puis `label ASC`. Pas d'écriture exposée au M1 (HP). ## 5. Autorisation ### 5.1 Déclaration des permissions Cf. § 2.1 (méthode `CommercialModule::permissions()`). ### 5.2 Mapping rôles MALIO ↔ permissions Cf. § 2.7 (matrice détaillée). ### 5.3 Synchronisation RBAC (3 sources OBLIGATOIRES — règle ABSOLUE Starseed n°8) 1. **`config/sidebar.php`** — section `commercial`, item « Répertoire clients » : ```php [ 'label' => 'sidebar.commercial.clients', 'to' => '/commercial/clients', 'icon' => 'mdi:account-group-outline', 'module' => 'commercial', 'permission' => 'commercial.clients.view', ], ``` 2. **`frontend/tests/e2e/_fixtures/personas.ts`** — attribuer les permissions : - Admin : `view` + `manage` + `accounting.view` + `accounting.manage` + `archive` - Bureau : `view` + `manage` - Compta : `view` + `accounting.view` + `accounting.manage` - Commerciale : `view` + `manage` - Usine : aucune 3. **`src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`** — miroir back du même persona. Synchronisation finale : `php bin/console app:sync-permissions`. ### 5.4 Vérification front - `usePermissions()` filtre l'item sidebar et masque l'onglet Comptabilité (`commercial.clients.accounting.view`). - Bouton « Archiver » visible si `commercial.clients.archive`. ## 6. Audit & dates ### 6.1 Audit complet via `#[Auditable]` - `Client` : `#[Auditable]` (tous les champs) - `ClientContact` : `#[Auditable]` - `ClientAddress` : `#[Auditable]` - `ClientRib` : `#[Auditable]` sur l'entité, **tous les champs auditables y compris `iban` et `bic`** (décision Matthieu en revue MR 29/05 : l'audit étant admin-only, l'équipe a besoin de ces infos pour le suivi comptable et la conformité). L'audit M2M sur `client.categories` est automatique (cf. doc audit-log) — produit `{categories: {added: [3], removed: [7]}}`. ### 6.2 Timestampable + Blamable Cf. § 2.6. Pattern Shared standard. ## 7. Règles de gestion (RG) ### Formulaire principal - **RG-1.01** : Au moins l'un des champs `firstName` (Prénom du contact principal) ou `lastName` (Nom du contact principal) doit être renseigné. Sinon → 422. - **RG-1.02** : Le champ `phoneSecondary` est optionnel et apparaît au clic sur un bouton `+` côté front. Maximum 2 téléphones (primary + secondary). Comportement purement front au niveau UI ; côté serveur, les 2 colonnes existent et sont distinctes. - **RG-1.03** : Les champs `distributor` et `broker` sont **mutuellement exclusifs** (au plus une seule des deux est renseignée). Tentative d'envoyer les deux → 422. Contrainte CHECK en base également : `NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)`. La liste front de `distributor` = clients ayant au moins une catégorie de type `DISTRIBUTEUR` ; idem pour `broker` avec `COURTIER`. ### Onglet Information - **RG-1.04** : Pour un utilisateur portant le rôle métier **Commerciale**, **tous** les champs de l'onglet Information (`description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount`) deviennent obligatoires lors d'un PATCH sur le groupe `client:write:information`. Pour les autres rôles, ces champs restent optionnels. Implémenté via un validator custom `ClientInformationCompletenessValidator` invoqué par le `ClientProcessor` quand le user porte le rôle Commerciale. ### Onglet Contact - **RG-1.05** : Un bloc Contact est valide dès lors que **au moins** `firstName` OU `lastName` est rempli. Contrainte BDD : `CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)`. Côté UI, le bouton « + Nouveau contact » est bloqué tant que le bloc en cours n'a pas Prénom OU Nom. - **RG-1.14** : **(validation Tristan 28/05)** L'onglet Contact est finalisable uniquement si **au moins 1 bloc Contact valide** existe. C'est-à-dire : POST `/api/clients` requiert que les futurs blocs Contact contiennent au moins un Contact (l'API n'exige rien à la création initiale du Client, mais le front bloque la finalisation tant que l'onglet Contact n'est pas validé avec ≥ 1 bloc). Côté back, la collection `client.contacts` peut rester vide tant que le client n'est pas considéré « complet » — la complétude est purement front au M1 (pas de state machine back). ### Onglet Adresse - **RG-1.06** : Si `isProspect = TRUE` sur une adresse, les champs/cases `isDelivery` et `isBilling` sont masqués côté UI et **forcés à FALSE** côté serveur. Contrainte CHECK : `NOT (is_prospect = TRUE AND (is_delivery = TRUE OR is_billing = TRUE))`. - **RG-1.07** : Si `isDelivery = TRUE`, la case `isProspect` est masquée côté UI et forcée à FALSE serveur (couvert par la même CHECK que RG-1.06). - **RG-1.08** : Si `isBilling = TRUE`, la case `isProspect` est masquée côté UI et forcée à FALSE serveur (couvert par la même CHECK). - **RG-1.09** : Le champ `city` est prérempli automatiquement à partir du `postalCode` via l'API **BAN** (api-adresse.data.gouv.fr). L'API est appelée **directement depuis le front** via un composable dédié `useAddressAutocomplete()`. Cas dégradé (API down) : le champ Ville devient un texte libre + toast d'avertissement. Validation serveur : `postalCode` matche `^[0-9]{4,5}$` ; pas de validation stricte de cohérence CP/Ville côté serveur. - **RG-1.10** : Au moins un des 3 sites (Châtellerault 86 / Saint-Jean 17 / Pommevic 82) doit être sélectionné sur chaque adresse. Validation : `Assert\Count(min: 1)` sur `clientAddress.sites`. - **RG-1.11** : Le champ `billingEmail` est visible et **obligatoire** uniquement si `isBilling = TRUE`. CHECK BDD : `(is_billing = FALSE AND billing_email IS NULL) OR (is_billing = TRUE AND billing_email IS NOT NULL)`. ### Onglet Comptabilité - **RG-1.12** : Le champ `bank` est visible et obligatoire **uniquement** si `paymentType.code = 'VIREMENT'`. Validation server-side dans le `ClientProcessor` : si `payment_type.code = VIREMENT` et `bank IS NULL` → 422. - **RG-1.13** : Les champs RIB (`label`, `bic`, `iban`) sont obligatoires si **au moins un bloc RIB est présent ET** `paymentType.code = 'LCR'`. C'est-à-dire : - Si `paymentType.code = LCR` ET `client.ribs.count() = 0` → 422 « Au moins un RIB est obligatoire pour le type LCR ». - Tentative de DELETE du dernier RIB d'un client en LCR → 409. - Pour les autres types de règlement, les RIBs sont optionnels (0..n). ### Onglet Contact (renforcement) - **RG-1.14** : (cf. ci-dessus) — au moins 1 bloc Contact valide pour finaliser l'onglet. ### Unicité - **RG-1.15** : ~~Unicité SIREN~~ — **supprimée (décision Q4, 29/05/2026)**. Le `siren` n'est plus contraint unique : un même SIREN peut être partagé (établissements multiples). Pas d'index `uq_client_siren_active`. - **RG-1.16** : Le `companyName` est unique (case-insensitive) parmi les clients non archivés ET non soft-deletés (index partiel `uq_client_company_name_active`). Doublon → 409 avec message `"Un client nommé \"{companyName}\" existe déjà."`. **Seule unicité métier conservée (Q4).** - **RG-1.17** : ~~Unicité email principal~~ — **supprimée (décision Q4, 29/05/2026)**. L'`email` principal n'est plus contraint unique (un même email peut servir plusieurs clients). Pas d'index `uq_client_email_active`. ### Normalisation serveur (formatage) - **RG-1.18** : `companyName` est **upper-cased** intégralement côté serveur avant validation et persistance (`mb_strtoupper(trim($v), 'UTF-8')`). Le client n'a pas besoin de saisir en majuscules ; la BDD stocke en majuscules. - **RG-1.19** : `firstName`, `lastName` (sur `Client` et `ClientContact`) sont **capitalize**-és serveur (`mb_convert_case(trim($v), MB_CASE_TITLE, 'UTF-8')`). Exemple : `JEAN dupont` → `Jean Dupont`. - **RG-1.20** : Les champs téléphone (`phonePrimary`, `phoneSecondary` sur `Client`, et idem sur `ClientContact`) sont **normalisés à chiffres uniquement** côté serveur (`preg_replace('/\D+/', '', $v)`). Stockage : `0612345678`. Le **format affichage `XX XX XX XX XX`** est de la responsabilité du front via un filter Vue dédié (cf. spec-front). - **RG-1.21** : `email` (`Client.email`, `ClientAddress.billingEmail`, `ClientContact.email`) est **lowercase** intégralement côté serveur (`mb_strtolower(trim($v), 'UTF-8')`). ### Archivage - **RG-1.22** : Un PATCH avec `{ "isArchived": true }` exige la permission `commercial.clients.archive`. Pose `isArchived = true` ET `archivedAt = now()`. Aucun autre champ ne peut être modifié dans la même requête. - **RG-1.23** : Un PATCH avec `{ "isArchived": false }` (restauration) exige la même permission. Pose `isArchived = false` ET `archivedAt = null`. Si la restauration entre en conflit avec une unicité (un autre client actif a pris le SIREN / email / nom entre-temps) → 409 « Restauration impossible : un autre client a pris le SIREN/email/nom entre-temps ». ### Liste / Détail - **RG-1.24** : `GET /api/clients` exclut par défaut les clients archivés (`is_archived = TRUE`) ET soft-deletés (`deleted_at IS NOT NULL`). Filtré dans le `ClientProvider`. - **RG-1.25** : Avec `?includeArchived=true`, les archivés sont inclus dans la liste. Les soft-deletés restent exclus au M1 (HP M2). - **RG-1.26** : Tri par défaut côté serveur : `companyName ASC`. ### Timestampable + Blamable - **RG-1.27** : Pattern Shared standard (cf. RG-1.15 à RG-1.17 du M0). Tous les actes (POST/PATCH/archive/RIB/contact/adresse) tracent `updatedAt` + `updatedBy`. `createdAt` + `createdBy` posés au POST initial. ### PATCH mix de groupes (mode strict) - **RG-1.28** : Si un PATCH contient des champs de **plusieurs groupes** de sérialisation et que l'utilisateur **n'a pas toutes les permissions** correspondantes, le `ClientProcessor` renvoie **403 Forbidden sur l'ensemble du payload** (mode strict — pas de filtrage silencieux). Le front est responsable de ne JAMAIS envoyer de champs hors-permission (les onglets masqués via `usePermissions()` ne génèrent pas de payload). Cette règle protège contre les appels API directs malveillants. Exemple : un Bureau qui envoie `{ "companyName": "...", "siren": "..." }` → 403, le message d'erreur précise « Champ `siren` requiert la permission `commercial.clients.accounting.manage` ». ### Catégorie sur ClientAddress (filtrage par type) - **RG-1.29** : Le `` Catégorie de l'onglet Adresse n'expose **que** les `Category` dont `categoryType.code IN ('SECTEUR', 'AUTRE')`. Les types `DISTRIBUTEUR` et `COURTIER` qualifient une **relation entre clients** (cf. RG-1.03) et n'ont pas de sens sur une adresse physique. Implémentation : `ClientAddressProvider` filtre côté serveur via paramètre de requête à l'endpoint `GET /api/categories?categoryType.code[]=SECTEUR&categoryType.code[]=AUTRE` (SearchFilter API Platform). Côté validation du POST/PATCH : si l'utilisateur tente de poster une catégorie de type DISTRIBUTEUR ou COURTIER sur une adresse → **422** avec violation `categories: "Type de catégorie non autorisé sur une adresse."`. ## 8. Tests à automatiser ### 8.1 Cas à couvrir (back — PHPUnit) - [ ] **RG-1.01** : POST sans firstName ni lastName → 422 - [ ] **RG-1.02** : POST avec phoneSecondary rempli → persistance OK ; PATCH ajoutant un 3e téléphone → côté API, 2 colonnes uniquement (test que le payload ne peut pas créer un 3e) - [ ] **RG-1.03** : POST avec distributor ET broker → 422 ; POST distributor seul → 201 - [ ] **RG-1.03** : POST distributor référençant un client SANS catégorie de type DISTRIBUTEUR → 422 (validation custom) - [ ] **RG-1.04** : PATCH onglet Information par un user Commerciale avec champs incomplets → 422 ; même PATCH par Admin → 200 - [ ] **RG-1.05** : POST contact sans firstName ni lastName → 422 (BDD CHECK lève une exception) - [ ] **RG-1.06/07/08** : POST adresse avec isProspect=true ET isDelivery=true → 422 / CHECK - [ ] **RG-1.09** : POST adresse avec postalCode invalide (3 chiffres) → 422 ; CP/ville incohérents → 200 (pas de validation stricte côté serveur) - [ ] **RG-1.10** : POST adresse sans aucun site → 422 - [ ] **RG-1.11** : POST adresse isBilling=true sans billingEmail → 422 ; POST isBilling=false avec billingEmail → 422 (CHECK) - [ ] **RG-1.12** : POST onglet Comptabilité avec paymentType=VIREMENT sans bank → 422 - [ ] **RG-1.13** : POST onglet Comptabilité paymentType=LCR sans RIB → 422 ; DELETE du dernier RIB en LCR → 409 - [ ] **RG-1.14** : front-driven uniquement, pas de test back - [ ] **RG-1.16** : POST avec `companyName` déjà pris → 409 ; POST avec même `companyName` après archivage de l'ancien → 201. SIREN et email dupliqués → 201 (plus d'unicité — RG-1.15/1.17 supprimées, Q4). - [ ] **RG-1.18** : POST `companyName="acme sas"` → BDD persiste `"ACME SAS"` - [ ] **RG-1.19** : POST `firstName="JEAN"`, `lastName="dupont"` → persiste `"Jean"`, `"Dupont"` - [ ] **RG-1.20** : POST `phonePrimary="06.12.34.56.78"` → persiste `"0612345678"` - [ ] **RG-1.21** : POST `email="Jean.DUPONT@ACME.FR"` → persiste `"jean.dupont@acme.fr"` - [ ] **RG-1.22/23** : PATCH isArchived=true par Bureau (sans `archive`) → 403 ; par Admin → 200 + archivedAt rempli ; PATCH isArchived=false sur un client archivé dont le SIREN a été repris → 409 - [ ] **RG-1.24/25** : GET liste sans flag → exclut archivés ; avec `?includeArchived=true` → inclut - [ ] **RG-1.26** : GET liste → tri companyName ASC - [ ] **RG-1.27** : POST + PATCH → createdAt/createdBy figés, updatedAt/updatedBy mis à jour - [ ] **RBAC** : Bureau, Commerciale, Compta sur chaque permission (matrice § 2.7) — 200/403 selon le verbe - [ ] **Compta accounting.view + accounting.manage** : GET client retourne les champs accounting ; PATCH onglet accounting par Compta → 200 ; PATCH onglet info / contacts / adresses par Compta → 403 - [ ] **Compta POST création** : Compta → 403 (pas de `manage` global) - [ ] **PATCH mix groupes** : Bureau envoie payload avec `companyName` (write:main) + `siren` (write:accounting) → **403 sur tout le payload** (strict, RG-1.28) - [ ] **Audit** : POST + PATCH + archive → audit_log avec entity_type='Client', `changes` correct ; **iban/bic présents dans le diff** (pas d'AuditIgnore, cf. § 6.1) - [ ] **Migration** : `make db-reset` → schéma OK ; migration en racine `migrations/` (namespace `DoctrineMigrations`, ordre garanti) ; 4 référentiels comptables seedés ; **4 CategoryType présents APRÈS db-reset** (via fixture idempotente, car le purger vide category_type) ; index partiel unique `uq_client_company_name_active` présent (un seul — cf. Q4) ### 8.2 Cas à couvrir (front — Vitest) - [ ] Composable `useClientsRepository()` : appel `useApi().get('/clients')`, exclusion archivés par défaut - [ ] Composable `useAddressAutocomplete()` : appel BAN, cas nominal + cas dégradé (timeout → toast) - [ ] Composable `useClientForm()` : workflow par onglet (validation incrémentale, PATCH partiel) - [ ] Filter `formatPhoneFR(value)` : `'0612345678'` → `'06 12 34 56 78'` - [ ] Composant `` : `` + bouton « + Ajouter » → bascule sur `/commercial/clients/new` - [ ] Composant `` : formulaire principal + onglets séquentiels, désactivation onglet suivant tant qu'actuel pas validé - [ ] Permissions : Compta accède à `/commercial/clients/{id}` mais l'onglet Comptabilité est en lecture seule ; Commerciale ne voit pas l'onglet Comptabilité du tout ### 8.3 Tests E2E **Non prévus au M1.** Règle ABSOLUE Starseed n°7. Extension des personas existants (Bureau / Compta / Commerciale) pour ajouter les permissions M1 — cf. § 5.3. ## 9. Hors-périmètre (HP) - **HP-M2-1** : **DELETE / soft delete d'un client.** Au M1, seul l'archivage est exposé. La colonne `deleted_at` existe mais aucune API ne la touche. À ouvrir en M2 si besoin métier (purge RGPD, doublon créé par erreur, etc.). - **HP-M2-2** : **CRUD admin des référentiels comptables** (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`). Au M1, seed initial via migration. Pas d'écran admin pour les éditer. - **HP-M2-3** : **CRUD admin de `CategoryType`** (toujours HP du M0 — le M1 seed seulement 4 types). - **HP-M2-4** : **Onglet Transport** (front + back). Au M1, placeholder blanc. - **HP-M2-5** : **Onglet Statistiques** (graphes commande / panier moyen / etc.). Placeholder blanc. - **HP-M2-6** : **Onglet Rapports** (PDF généré). Placeholder blanc. - **HP-M2-7** : **Onglet Échanges** (timeline d'emails / appels). Placeholder blanc. - **HP-M2-8** : **Restauration d'un client soft-deleted** (post-DELETE M2). Pas pertinent au M1. - **HP-M2-9** : **Export CSV** (en plus du XLSX). À étudier si besoin métier. - **HP-M2-10** : ~~Compta en édition de l'onglet Comptabilité~~ — **devenu nominal au M1** suite à revue MR Matthieu 29/05 (cf. § 2.7 et § 5.2). HP supprimé. - **HP-M2-11** : **Périmètre Commerciale** (« consultation selon périmètre » — formulation floue du doc). Au M1, Commerciale voit **tous** les clients en consultation (sauf Comptabilité). Si besoin de cloisonner par portefeuille (un commercial ne voit que SES clients), à spec dédiée. - **HP-M2-12** : ~~Création par Compta limitée à l'onglet Comptabilité~~ — Compta n'a toujours pas le droit de **créer** un client au M1 (pas de `manage` global). Si demande métier future, à spec dédiée. - **HP-M2-13** : **Référencement entrant** (autres modules ajoutent une FK `client_id`). Les modules Commandes, Factures, etc. ajouteront leurs FK quand ils arriveront. Aucun changement côté Client. - **HP-M2-14** : **Validation IBAN/BIC stricte côté serveur**. Au M1, validation Symfony standard `Assert\Iban` et `Assert\Bic` côté entité `ClientRib`. Pas de check externe (banque réelle, etc.). - **HP-M2-15** : **Validation SIREN stricte** (algorithme Luhn). Au M1, validation `Assert\Length(min: 9, max: 9)` + `Assert\Regex('/^\d{9}$/')` + algorithme Luhn dans un validator custom. - **HP-M2-16** : **Distributor / Broker — sortir de l'auto-référence Client**. Si un jour les distributeurs/courtiers deviennent une entité distincte (avec des attributs métiers spécifiques), refacto en entité `Partner` ou similaire. Au M1, on garde l'auto-référence simple. ## 10. Liens & dépendances ### Liens - Spec front : [`./spec-front.md`](./spec-front.md) - Maquette Figma : `https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898` - Spec M0 catégories : [`../M0-categories/spec-back.md`](../M0-categories/spec-back.md) - Doc audit-log : [`../../audit-log.md`](../../audit-log.md) - BAN api : `https://adresse.data.gouv.fr/api-doc/adresse` ### Dépendances amont (déjà en place dans Starseed) - Module `Catalog` (M0) : `Category` + `CategoryType` — M2M `client_category` (à étendre par seed M1 sur `CategoryType`) - Module `Sites` : `Site` (3 sites seedés 86/17/82) — M2M `client_address_site` - Module `Core` : `User`, `Role`, `Permission`, `Audit`, JWT - `Shared` : `TimestampableBlamableTrait` + `Subscriber` - API Platform 4 + Doctrine ORM + PostgreSQL 16 ### Specs futures qui dépendent du M1 - **M-Fournisseurs** : pattern réutilisable de `Client` (entités jumelles `Supplier` / `SupplierContact` / `SupplierAddress` / `SupplierRib` + référentiels comptables partagés). - **M-Prestas** : idem. - **M-Commandes** : FK `client_id` sur les commandes. - **M-Factures** : FK `client_id` + `client_address_id` (adresse facturation). --- ## 📦 Tickets Lesstime générés **TaskGroup Lesstime** : à créer — `M1 — Répertoire clients` (projet `ERP / Starseed`, projectId=6). > Les tickets précis (back + front + intégration) seront découpés et créés dans Lesstime dans un livrable séparé. Ordre indicatif : > 1. **Migration BDD M1** (tables client + sous-collections + référentiels comptables + index partiels + seed CategoryType + seed refs comptables) > 2. **Entités + Repositories** (Client, ClientContact, ClientAddress, ClientRib, TvaMode, PaymentDelay, PaymentType, Bank) > 3. **Provider + Processor** (ClientProvider, ClientProcessor — incl. normalisation, archivage, conditional accounting) > 4. **Référentiels lecture seule** (4 endpoints GetCollection + Get) > 5. **Sous-ressources** (ClientContactProcessor, ClientAddressProcessor, ClientRibProcessor) > 6. **Export XLSX** (controller custom) > 7. **RBAC** : `CommercialModule::permissions()` + sync 3 sources + tests PHPUnit personas > 8. **Tests PHPUnit** : matrice RG-1.01 → RG-1.27 (8.1) > 9. **Front : page Répertoire** (`/commercial/clients`) + composable `useClientsRepository()` > 10. **Front : page Création** (`/commercial/clients/new`) + composables `useClientForm()`, `useAddressAutocomplete()` > 11. **Front : page Consultation** (`/commercial/clients/{id}`) > 12. **Front : page Modification** (`/commercial/clients/{id}/edit`) > 13. **Front : filter `formatPhoneFR` + tests Vitest** (cf. 8.2) > 14. **i18n + Sidebar** (clé `sidebar.commercial.clients`, traductions) ### Actions manuelles à faire dans Lesstime (Matthieu) 1. Créer le TaskGroup `M1 — Répertoire clients` dans le projet `ERP / Starseed`. 2. Créer les ~14 tickets ci-dessus avec dépendances séquentielles (back avant front, migration en tête). 3. Mettre à jour le frontmatter de ce fichier (`lesstime_taskgroup_id`) avec l'id réel.