Files
Starseed/docs/specs/M1-clients/spec-back.md
T

55 KiB

module, nom, ecran, owner_spec, backup_spec, version, date_redaction, spec_front, maquette_figma, lesstime_taskgroup_id, lesstime_project_id, statut_global, depend_de
module nom ecran owner_spec backup_spec version date_redaction spec_front maquette_figma lesstime_taskgroup_id lesstime_project_id statut_global depend_de
M1 Répertoire clients repertoire-clients Matthieu Tristan V0 2026-05-28 ./spec-front.md https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898 23 6 en_dev
M0-categories
Sites
Core
Shared

Spec back — Module 1 : Répertoire clients

1. Contexte

Cette spec complète et précise la spec front V0 (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)
  • SharedTimestampableBlamableTrait + 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

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 — 3 contraintes sur Client

Index uniques partiels (WHERE is_archived = false AND deleted_at IS NULL) sur :

  1. LOWER(company_name) — unicité du nom d'entreprise
  2. siren — unicité du SIREN
  3. LOWER(email) — unicité de l'email principal

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
  • #[AuditIgnore] sur les champs sensibles : ClientRib.iban, ClientRib.bic
  • 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

Note : 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) :

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 : App\Module\Commercial\Infrastructure\Doctrine\Migrations (modulaire, post-init). Fichier : Version20260601000000.php (à dater par le dev).

-- =====================================================================
-- 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és métier (partielles : on ignore archives + soft-delete)
CREATE UNIQUE INDEX uq_client_company_name_active
    ON client (LOWER(company_name))
    WHERE is_archived = FALSE AND deleted_at IS NULL;

CREATE UNIQUE INDEX uq_client_siren_active
    ON client (siren)
    WHERE siren IS NOT NULL AND is_archived = FALSE AND deleted_at IS NULL;

CREATE UNIQUE INDEX uq_client_email_active
    ON client (LOWER(email))
    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 :

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). Le seed est fait via migration ou fixture déclenchée à chaque make db-reset.

3.4 Entité Client — squelette

<?php

declare(strict_types=1);

namespace App\Module\Commercial\Domain\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\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;

    // 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<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). ClientRib.iban et ClientRib.bic portent #[AuditIgnore] (champs sensibles).

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=<code> (filtre par type de catégorie via SearchFilter)
    • search=<text> (recherche fuzzy sur companyName + lastName + email)
  • 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) :
{
  "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 (companyName, siren, ou email — RG-1.15 / RG-1.16 / RG-1.17)
    • 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 » :
[
    'label'      => 'sidebar.commercial.clients',
    'to'         => '/commercial/clients',
    'icon'       => 'mdi:account-group-outline',
    'module'     => 'commercial',
    'permission' => 'commercial.clients.view',
],
  1. frontend/tests/e2e/_fixtures/personas.ts — attribuer les permissions :

    • Admin : view + manage + accounting.view + accounting.manage + archive
    • Bureau : view + manage
    • Compta : view + accounting.view
    • Commerciale : view + manage
    • Usine : aucune
  2. 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 sauf ceux marqués #[AuditIgnore])
  • ClientContact : #[Auditable]
  • ClientAddress : #[Auditable]
  • ClientRib :
    • #[Auditable] sur l'entité
    • #[AuditIgnore] sur iban et bic (RGPD / sécurité)

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 : Le siren est unique parmi les clients non archivés ET non soft-deletés (index partiel uq_client_siren_active). Tentative de doublon → 409 avec message "Un client avec le SIREN \"{siren}\" existe déjà.".
  • 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.
  • RG-1.17 : L'email principal est unique (case-insensitive) parmi les clients non archivés ET non soft-deletés (index partiel uq_client_email_active). Doublon → 409.

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 dupontJean 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.

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.15/16/17 : POST avec SIREN/companyName/email déjà pris → 409 ; POST avec même SIREN/companyName/email après archivage → 201
  • 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 : GET client retourne les champs accounting ; PATCH accounting par Compta → 403
  • Audit : POST + PATCH + archive → audit_log avec entity_type='Client', changes correct ; iban/bic absents du diff (AuditIgnore)
  • Migration : make db-reset → schéma OK, seed des 4 référentiels + CategoryType (DISTRIBUTEUR/COURTIER/SECTEUR/AUTRE) présent ; index partiels présents

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é (réintroduction de la ligne du tableau du .docx invalidée par décision Tristan 28/05). Demande explicite + décision archi à formaliser.
  • 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é (idem HP-M2-10 — invalidée au M1).
  • 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

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.