---
# === IDENTITÉ ===
module: M1
nom: "Répertoire clients"
ecran: repertoire-clients
owner_spec: Matthieu
backup_spec: Tristan
version: V1
date_redaction: 2026-05-28
# Historique : V1 (2026-06-03) — Refonte contact : suppression du contact principal inline
#   du Client (firstName/lastName/phonePrimary/phoneSecondary/email retirés de la table client).
#   Les contacts vivent uniquement dans ClientContact. Cf. docs/specs/M1-clients/refonte-contact/README.md

# === 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
<?php

declare(strict_types=1);

namespace App\Module\Commercial;

final class CommercialModule
{
    public const string ID     = 'commercial';
    public const string LABEL  = 'Commercial';
    public const bool REQUIRED = false;

    /**
     * Permissions RBAC exposees par le module Commercial. Granularite alignee
     * sur Core/Catalog (view + manage), plus deux permissions dediees a
     * l'onglet Comptabilite et a l'archivage.
     *
     * @return array<int, array{code: string, label: string}>
     */
    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      |
| (contact inline   |        +-----------------------+        +--------------+
|  retiré V1 —      |--1:n-->|    client_contact     |        |   site       |
|  firstName,       |        +-----------------------+        |  (Sites)     |
|  lastName, phones,|                                         +--------------+
|  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,
    -- Contact inline retiré (V1, refonte-contact) : first_name / last_name / phone_primary /
    -- phone_secondary / email vivent désormais uniquement dans client_contact (onglet Contacts).
    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 (facultatif pour tous — RG-1.04 supprimée)
    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 taxonomie — type unique `CLIENT` + `Category.code` (refonte ERP-78)

> **⚠ Refonte ERP-78 (décision produit 01/06) — le modèle ci-dessous remplace l'ancien.**
> Historique : à l'origine (#38), `DISTRIBUTEUR` / `COURTIER` / `SECTEUR` / `AUTRE` étaient des **`category_type`**. Le modèle a été **inversé** :
>
> - **UN SEUL `category_type` : `CLIENT`** (code `CLIENT`, label « Client »).
> - `Distributeur` / `Courtier` / `Secteur` / `Autre` (+ catégories métier fines) sont désormais des **`Category`** rattachées au type `CLIENT`.
> - Le filtrage métier ne se fait plus sur le **type** mais sur un **`code` stable porté par la `Category`** (NOT NULL, unique parmi les actifs — index partiel `uq_category_code`). Le code est un **slug MAJUSCULE auto-généré du nom** (`CategoryCodeGenerator`), figé à la création, et exposé en **lecture seule** (groupe `category:read`). Les codes `DISTRIBUTEUR` / `COURTIER` (anciennement portés par le type) sont reportés sur les `Category` correspondantes.

Seed cible (migration corrective `Version20260602100000`, namespace racine) :

```sql
-- Type unique
INSERT INTO category_type (code, label) VALUES ('CLIENT', 'Client') ON CONFLICT (code) DO NOTHING;
-- Catégories système sous CLIENT (codes stables pilotant les RG)
-- Distributeur -> DISTRIBUTEUR, Courtier -> COURTIER, Secteur -> SECTEUR, Autre -> AUTRE
```

> **Note** : le CRUD admin de `CategoryType` reste HP (cf. M0). Le `code` de `Category` n'est PAS saisissable via l'API (auto-généré côté serveur).
>
> **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` / `category_type`** (entités M0 mappées) avant `load()` → un seed posé uniquement en migration disparaît en dev/test. Donc :
> 1. **Migration** (`ON CONFLICT` / guards `NOT EXISTS`) → sert en **prod** (pas de fixtures).
> 2. **Fixtures idempotentes** (`CategoryTypeFixtures` → type CLIENT ; `CategoryFixtures` → catégories codées sous CLIENT) → survivent au `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-78 (cette refonte) atterrit avant ERP-68. `CategoryFixtures` / `ClientFixtures` ont été adaptées au type unique CLIENT + codes (les tiers distributeur/courtier portent les `Category` de code DISTRIBUTEUR/COURTIER).

### 3.4 Entité `Client` — squelette

```php
<?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\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Provider\ClientProvider;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository;
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\Validator\Constraints as Assert;

#[ApiResource(
    operations: [
        new GetCollection(
            security: "is_granted('commercial.clients.view')",
            normalizationContext: ['groups' => ['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;

    // Contact inline retiré (V1, refonte-contact) : firstName / lastName / phonePrimary /
    // phoneSecondary / email ne sont plus portés par Client — ils vivent dans ClientContact
    // (onglet Contacts). La garantie « ≥ 1 contact nommé » est portée par RG-1.05 + RG-1.14.

    // 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<int, Category> */
    #[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<int, ClientContact> */
    #[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    private Collection $contacts;

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

    /** @var Collection<int, ClientRib> */
    #[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`)
  - `categoryCode=<code>` (filtre les clients ayant ≥ 1 `Category` de ce code stable — ERP-78 ; ex. `DISTRIBUTEUR`, `COURTIER`)
  - `search=<text>` (recherche fuzzy sur companyName + contacts liés `client_contact` (firstName / lastName / email) via LEFT JOIN groupé par `client.id` — décision D1, refonte-contact)
- **Tri par défaut** : `companyName ASC`
- **Pagination** : front via `<MalioDataTable>` (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",
  "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 supprimée V1 — la complétude du contact est portée par l'onglet Contacts : RG-1.05 / RG-1.14)
    - 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**~~ _(SUPPRIMÉE — V1, 2026-06-03, refonte-contact)_ : le contact principal inline est retiré du `Client`. La garantie « au moins un contact nommé » est désormais portée par **RG-1.05** (bloc Contact valide) + **RG-1.14** (≥ 1 bloc Contact) sur `ClientContact`.
- ~~**RG-1.02**~~ _(SUPPRIMÉE du Client — V1, refonte-contact)_ : plus de téléphones inline sur le `Client`. Le « maximum 2 téléphones » reste applicable aux blocs `ClientContact` (normalisation RG-1.20).
- **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)`. Un `distributor` référencé doit porter une **`Category` de code `DISTRIBUTEUR`** ; un `broker` une **`Category` de code `COURTIER`** — sinon 422. _(Refonte ERP-78 : le filtrage se fait sur le `code` de la `Category`, plus sur le type — `ClientProcessor::hasCategoryCode`.)_ La liste front de `distributor` = clients ayant une catégorie de code `DISTRIBUTEUR`, via `GET /api/clients?categoryCode=DISTRIBUTEUR` ; idem `broker` avec `COURTIER`.

### Onglet Information

- ~~**RG-1.04**~~ _(SUPPRIMÉE)_ : l'onglet Information est désormais **facultatif pour tous les rôles**, y compris Commerciale. Les champs `description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount` restent optionnels (colonnes nullable, aucune validation de complétude). Le validator `ClientInformationCompletenessValidator` et le gating par rôle métier dans le `ClientProcessor` ont été retirés.

### 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.30** _(ajoutée — correctif incohérence spec-front/spec-back)_ : à la **validation complète de l'onglet Comptabilité**, les six champs scalaires `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType` sont **obligatoires** (alignement sur spec-front § Onglet Comptabilité). Colonnes `nullable` en base (l'onglet est rempli dans un second temps, et l'onglet principal ne les envoie pas) + validateur contextuel `ClientAccountingCompletenessValidator` invoqué par le `ClientProcessor` (parti pris : nullable + validateur d'onglet plutôt qu'un `Assert\NotBlank` sur l'entité). Déclenchement : uniquement quand **les six champs sont présents dans le payload** (le front les envoie toujours ensemble via « Valider ») ; un PATCH ciblant un sous-ensemble de champs comptables (édition ponctuelle) n'est pas soumis à la complétude. Chaque champ manquant → 422 sur son `propertyPath` (mapping inline front, ERP-101). `bank` reste hors complétude (conditionnel RG-1.12).
- **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 `ClientContact` ; scope `Client` retiré en V1) 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 `ClientContact` ; scope `Client` retiré en V1) 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` (`ClientAddress.billingEmail`, `ClientContact.email` ; `Client.email` retiré en V1) 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 code)

- **RG-1.29** _(refonte ERP-78)_ : sur une adresse, les `Category` de **code `DISTRIBUTEUR` ou `COURTIER`** sont **interdites** — elles qualifient une **relation entre clients** (cf. RG-1.03) et n'ont pas de sens sur une adresse physique. **Toute autre** catégorie (type unique CLIENT) est autorisée. Validation du POST/PATCH : poster une catégorie de code DISTRIBUTEUR/COURTIER sur une adresse → **422** avec violation `categories: "Type de catégorie non autorisé sur une adresse."` (`ClientAddress::validateCategoryCodes`). Côté front, le `<MalioSelectCheckbox>` Catégorie de l'onglet Adresse exclut les `Category` de code `DISTRIBUTEUR` / `COURTIER` (le `code` est exposé en lecture sur `/api/categories`).

## 8. Tests à automatiser

### 8.1 Cas à couvrir (back — PHPUnit)

- [ ] ~~RG-1.01~~ _(supprimée V1)_ : la complétude du contact est couverte par RG-1.05 / RG-1.14 sur `ClientContact`
- [ ] ~~RG-1.02~~ _(supprimée du Client V1)_ : plus de téléphones inline sur le Client (téléphones testés sur `ClientContact`)
- [ ] **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 code DISTRIBUTEUR → 422 (validation custom `ClientProcessor::hasCategoryCode`)
- [x] ~~**RG-1.04**~~ _(SUPPRIMÉE)_ : onglet Information facultatif pour tous les rôles (plus de validation de complétude)
- [ ] **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"` (via un bloc `ClientContact`) → persiste `"Jean"`, `"Dupont"`
- [ ] **RG-1.20** : POST `phonePrimary="06.12.34.56.78"` (via un bloc `ClientContact`) → persiste `"0612345678"`
- [ ] **RG-1.21** : POST `email="Jean.DUPONT@ACME.FR"` (via `ClientContact` ou `ClientAddress.billingEmail`) → 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 `<ClientsRepositoryPage>` : `<MalioDataTable>` + bouton « + Ajouter » → bascule sur `/commercial/clients/new`
- [ ] Composant `<ClientCreatePage>` : 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.
