diff --git a/docs/specs/M1-clients/spec-back.md b/docs/specs/M1-clients/spec-back.md new file mode 100644 index 0000000..3b3d2cc --- /dev/null +++ b/docs/specs/M1-clients/spec-back.md @@ -0,0 +1,1056 @@ +--- +# === IDENTITÉ === +module: M1 +nom: "Répertoire clients" +ecran: repertoire-clients +owner_spec: Matthieu +backup_spec: Tristan +version: V0 +date_redaction: 2026-05-28 + +# === LIENS === +spec_front: ./spec-front.md +maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898" + +# === LIEN LESSTIME === +lesstime_taskgroup_id: 0 +lesstime_project_id: 6 +statut_global: en_redaction + +# === DÉPENDANCES AMONT === +depend_de: + - M0-categories # Category + CategoryType déjà mergés sur develop + - Sites # SitesModule + 3 sites seedés (86 / 17 / 82) déjà en place + - Core # User, Role, Permission, Audit, JWT déjà en place + - Shared # TimestampableBlamableTrait + Subscriber (ERP-52) +--- + +# Spec back — Module 1 : Répertoire clients + +## 1. Contexte + +Cette spec **complète et précise** la [spec front V0](./spec-front.md) (`M1-reportoire-clients.docx` du 22/05/2026) avec tout ce qui touche au back : décisions d'archi, modèle de données, migrations, API REST, RBAC, règles de gestion, tests, hors-périmètre. + +**Module cible** : extension du module `Commercial` existant (`src/Module/Commercial/`). L'objectif est de fournir la première sous-section métier du Commercial (Clients), avec un pattern réutilisable plus tard pour Suppliers / Prestas. + +**Dépendances déjà en place sur `develop`** : +- `Catalog` (M0) → `Category` + `CategoryType` (sera étendu par seed M1 : `DISTRIBUTEUR`, `COURTIER`, `SECTEUR`, `AUTRE`) +- `Sites` → 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82) +- `Shared` → `TimestampableBlamableTrait` + `Subscriber` (ERP-52) +- `Core` → User, Role, Permission, Audit + +## 2. Décisions d'archi + +### 2.1 Module — Extension de `Commercial`, pas un module dédié + +Le client M1 vit sous `src/Module/Commercial/` (déjà existant et déclaré). Pas de nouveau module `Clients`. Rationale : + +- Cohérence avec le pattern MALIO : Commercial = couche **Tiers** (Clients + Fournisseurs + Prestataires). Le module existant porte déjà `Suppliers`. +- Évite la prolifération de modules micro-scope. +- Si le scope explose à terme, on extrait via le pattern `Shared/Domain/Contract/`. + +Le `CommercialModule.php` actuel est quasi vide — on l'enrichit avec la méthode statique `permissions()` (aujourd'hui absente) : + +```php + + */ + public static function permissions(): array + { + return [ + ['code' => 'commercial.clients.view', 'label' => 'Voir les clients'], + ['code' => 'commercial.clients.manage', 'label' => 'Créer / modifier les clients (hors onglet Comptabilité)'], + ['code' => 'commercial.clients.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un client'], + ['code' => 'commercial.clients.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un client'], + ['code' => 'commercial.clients.archive', 'label' => 'Archiver / restaurer un client'], + // Note : la permission Suppliers n'est pas ajoutee ici (deja + // gere par un autre ticket / a etendre quand le M-Fournisseurs + // sera spec). + ]; + } +} +``` + +### 2.2 IDs entier auto-increment Postgres natif + +Cohérent avec le M0 et l'ensemble Starseed. Pas d'UUID, pas de ULID. + +### 2.3 Soft delete vs Archive — **deux mécanismes distincts** + +**Décision validée par Tristan le 28/05 (s'écarte du pattern M0)** : + +| Mécanisme | Colonne | Visibilité par défaut | Restauration | Utilisateur | +|---|---|---|---|---| +| **Archive** (fonctionnel) | `is_archived` (bool, default false) | masqué | Oui (toggle UI) | Admin via permission `commercial.clients.archive` | +| **Soft delete** (technique) | `deleted_at` (timestamp nullable) | masqué | HP M2+ | Aucun rôle au M1 (HP) | + +Le M1 expose **uniquement le mécanisme Archive**. Le soft delete reste préparé en BDD (la colonne existe sur toutes les nouvelles entités Tiers via `TimestampableBlamableTrait` étendu — cf. § 2.6) mais n'est **pas exposé** via API au M1. + +**Conséquences** : +- Le `DELETE /api/clients/{id}` n'est **pas exposé** au M1 (404 si appelé). Activé en HP M2. +- `GET /api/clients?includeArchived=true` permet de voir les archivés (permission requise : `commercial.clients.view`). +- PATCH `{ "isArchived": true }` archive ; PATCH `{ "isArchived": false }` restaure. +- Les unicités métier (SIREN, nom, email) ignorent les archivés ET les soft-deletés (cf. § 3.5). + +### 2.4 Unicité partielle Postgres — 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) : + +```php +final class ClientFieldNormalizer +{ + public function normalizeCompanyName(?string $value): ?string + { + return $value === null ? null : mb_strtoupper(trim($value), 'UTF-8'); + } + + public function normalizePersonName(?string $value): ?string + { + if ($value === null) { + return null; + } + $value = trim($value); + return $value === '' ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8'); + } + + public function normalizeEmail(?string $value): ?string + { + return $value === null ? null : mb_strtolower(trim($value), 'UTF-8'); + } + + public function normalizePhone(?string $value): ?string + { + // Persistance : chiffres uniquement (10 attendus pour FR). + return $value === null ? null : preg_replace('/\D+/', '', $value); + } +} +``` + +Le **formatage `XX XX XX XX XX`** est fait à l'affichage côté front (filter Vue `formatPhoneFR()`). Le back stocke `0612345678` (10 chiffres). + +## 3. Modèle de données + +### 3.1 Diagramme + +``` ++-------------------+ +-----------------------+ +--------------+ +| client |--n:m-->| client_category |<--n:m--| category | +| | +-----------------------+ | (Catalog) | +| id (PK) | +--------------+ +| company_name | +| first_name | +-----------------------+ +--------------+ +| last_name |--1:n-->| client_contact | | site | +| phone_primary | +-----------------------+ | (Sites) | +| phone_secondary | +--------------+ +| email | +-----------------------+ ^ +| distributor_id |--1:n-->| client_address |--n:m---------+ +| broker_id | +-----------------------+ +| triage_service | | +| is_archived | +--n:m--+--> client_contact +| deleted_at | | +| description | +-----------------------+ +--------------+ +| competitors |--1:n-->| client_rib | | tva_mode | +| founded_at | +-----------------------+ | payment_* | +| employees_count | | bank | +| revenue_amount | siren (Oui) +--------------+ +| director_name | account_number (Oui) +| profit_amount | tva_mode_id (FK) ++-------------------+ n_tva + ^ ^ payment_delay_id (FK) + | | payment_type_id (FK) + | +-- distributor_id bank_id (FK nullable) + +---- broker_id (auto-references via FK Client) +``` + +**Particularités** : +- `client.distributor_id` et `client.broker_id` sont **2 FK auto-référentes** vers `client.id`. Une seule est non-NULL à la fois (RG-1.03). +- `client_address` est rattachée à `n` `site` (M2M via `client_address_site`) et à `n` `client_contact` (M2M via `client_address_contact`). +- `client_rib` appartient à un `Client` (1:n). +- Les colonnes Information (description, competitors, founded_at, employees_count, revenue_amount, director_name, profit_amount) sont **sur Client lui-même** (1:1 conceptuel — pas d'entité Information séparée). + +### 3.2 Migration Doctrine — SQL Postgres + +Namespace : `App\Module\Commercial\Infrastructure\Doctrine\Migrations` (modulaire, post-init). Fichier : `Version20260601000000.php` (à dater par le dev). + +```sql +-- ===================================================================== +-- Référentiels comptables (4 tables statiques seedées par le M1) +-- ===================================================================== + +CREATE TABLE tva_mode ( + id SERIAL PRIMARY KEY, + code VARCHAR(30) NOT NULL UNIQUE, + label VARCHAR(120) NOT NULL, + position INT NOT NULL DEFAULT 0 +); + +CREATE TABLE payment_delay ( + id SERIAL PRIMARY KEY, + code VARCHAR(30) NOT NULL UNIQUE, + label VARCHAR(120) NOT NULL, + position INT NOT NULL DEFAULT 0 +); + +CREATE TABLE payment_type ( + id SERIAL PRIMARY KEY, + code VARCHAR(30) NOT NULL UNIQUE, + label VARCHAR(120) NOT NULL, + position INT NOT NULL DEFAULT 0 +); + +CREATE TABLE bank ( + id SERIAL PRIMARY KEY, + code VARCHAR(30) NOT NULL UNIQUE, + label VARCHAR(120) NOT NULL, + position INT NOT NULL DEFAULT 0 +); + +-- Seed M1 — référentiels comptables (cf. décision Tristan 28/05). +INSERT INTO tva_mode (code, label, position) VALUES + ('FRANCE_VENTES', 'France (ventes)', 10), + ('EXPORT_VENTES', 'Export (ventes)', 20), + ('INTRACOM_VENTES', 'Intracom (ventes)', 30); + +INSERT INTO payment_delay (code, label, position) VALUES + ('J15', '15 jours', 10), + ('J30', '30 jours', 20), + ('A_RECEPTION', 'À réception', 30); + +INSERT INTO payment_type (code, label, position) VALUES + ('VIREMENT', 'Virement', 10), + ('LCR', 'LCR', 20), + ('NON_SOUMISE', 'Non soumise', 30), + ('CHEQUE', 'Chèque', 40); + +INSERT INTO bank (code, label, position) VALUES + ('SG', 'Société Générale', 10), + ('CIC', 'CIC', 20), + ('CA', 'Crédit Agricole', 30); + +-- ===================================================================== +-- Table principale `client` +-- ===================================================================== + +CREATE TABLE client ( + id SERIAL PRIMARY KEY, + -- Formulaire principal + company_name VARCHAR(180) NOT NULL, + first_name VARCHAR(120), + last_name VARCHAR(120), + phone_primary VARCHAR(20) NOT NULL, + phone_secondary VARCHAR(20), + email VARCHAR(180) NOT NULL, + distributor_id INT REFERENCES client(id) ON DELETE SET NULL, + broker_id INT REFERENCES client(id) ON DELETE SET NULL, + triage_service BOOLEAN NOT NULL DEFAULT FALSE, + -- Onglet Information (Commerciale obligatoire — RG-1.04 — null sinon) + description TEXT, + competitors VARCHAR(255), + founded_at DATE, + employees_count INT, + revenue_amount NUMERIC(15,2), + director_name VARCHAR(120), + profit_amount NUMERIC(15,2), + -- Onglet Comptabilité — Admin seul + siren VARCHAR(20), + account_number VARCHAR(40), + tva_mode_id INT REFERENCES tva_mode(id) ON DELETE RESTRICT, + n_tva VARCHAR(40), + payment_delay_id INT REFERENCES payment_delay(id) ON DELETE RESTRICT, + payment_type_id INT REFERENCES payment_type(id) ON DELETE RESTRICT, + bank_id INT REFERENCES bank(id) ON DELETE RESTRICT, + -- Archive (mécanisme exposé M1) + is_archived BOOLEAN NOT NULL DEFAULT FALSE, + archived_at TIMESTAMPTZ, + -- Soft delete (préparé, non exposé au M1) + deleted_at TIMESTAMPTZ, + -- Timestampable + Blamable (cf. Shared) + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + created_by INT REFERENCES "user"(id) ON DELETE SET NULL, + updated_by INT REFERENCES "user"(id) ON DELETE SET NULL, + -- Une seule des deux FK distributor/broker doit être non-null (RG-1.03) + CONSTRAINT chk_client_distrib_or_broker + CHECK (NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)) +); + +CREATE INDEX idx_client_is_archived ON client(is_archived); +CREATE INDEX idx_client_deleted_at ON client(deleted_at); +CREATE INDEX idx_client_distributor_id ON client(distributor_id); +CREATE INDEX idx_client_broker_id ON client(broker_id); +CREATE INDEX idx_client_created_by ON client(created_by); +CREATE INDEX idx_client_updated_by ON client(updated_by); + +-- Unicité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 : + +```sql +INSERT INTO category_type (code, label, position) VALUES + ('DISTRIBUTEUR', 'Distributeur', 10), + ('COURTIER', 'Courtier', 20), + ('SECTEUR', 'Secteur', 30), + ('AUTRE', 'Autre', 99); +``` + +> **Note** : le CRUD admin de `CategoryType` reste HP (cf. M0). Le seed est fait via migration ou fixture déclenchée à chaque `make db-reset`. + +### 3.4 Entité `Client` — squelette + +```php + ['client:read', 'default:read']], + provider: ClientProvider::class, + ), + new Get( + security: "is_granted('commercial.clients.view')", + normalizationContext: ['groups' => ['client:read', 'client:read:accounting', 'default:read']], + // L'objet retourne contient TOUS les groupes ; le filtre des + // champs comptables se fait au niveau Provider en fonction de + // is_granted('commercial.clients.accounting.view'). + provider: ClientProvider::class, + ), + new Post( + security: "is_granted('commercial.clients.manage')", + normalizationContext: ['groups' => ['client:read', 'default:read']], + denormalizationContext: ['groups' => ['client:write:main']], + processor: ClientProcessor::class, + ), + new Patch( + security: "is_granted('commercial.clients.manage')", + // Le ClientProcessor inspecte les groupes effectivement envoyes + // pour autoriser/refuser onglet par onglet (cf. § 2.8 + § 5). + // Patch sur les champs comptables exige is_granted(accounting.manage). + normalizationContext: ['groups' => ['client:read', 'default:read']], + denormalizationContext: ['groups' => [ + 'client:write:main', + 'client:write:information', + 'client:write:accounting', + ]], + provider: ClientProvider::class, + processor: ClientProcessor::class, + ), + // Pas de Delete au M1 (HP M2). L'archivage est fait via PATCH + // { isArchived: true } gere par le ClientProcessor avec security + // additionnelle is_granted('commercial.clients.archive'). + ], +)] +#[ORM\Entity(repositoryClass: DoctrineClientRepository::class)] +#[ORM\Table(name: 'client')] +#[Auditable] +class Client implements TimestampableInterface, BlamableInterface +{ + use TimestampableBlamableTrait; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['client:read'])] + private ?int $id = null; + + #[ORM\Column(length: 180)] + #[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')] + #[Assert\Length(min: 2, max: 180, normalizer: 'trim')] + #[Groups(['client:read', 'client:write:main'])] + private ?string $companyName = null; + + // RG-1.01 — first_name OU last_name obligatoire (validation Assert\Callback + // au niveau de l'entite, levee dans le Processor). + #[ORM\Column(length: 120, nullable: true)] + #[Assert\Length(max: 120, normalizer: 'trim')] + #[Groups(['client:read', 'client:write:main'])] + private ?string $firstName = null; + + #[ORM\Column(length: 120, nullable: true)] + #[Assert\Length(max: 120, normalizer: 'trim')] + #[Groups(['client:read', 'client:write:main'])] + private ?string $lastName = null; + + #[ORM\Column(length: 20)] + #[Assert\NotBlank] + #[Groups(['client:read', 'client:write:main'])] + private ?string $phonePrimary = null; + + #[ORM\Column(length: 20, nullable: true)] + #[Groups(['client:read', 'client:write:main'])] + private ?string $phoneSecondary = null; + + #[ORM\Column(length: 180)] + #[Assert\NotBlank] + #[Assert\Email] + #[Groups(['client:read', 'client:write:main'])] + private ?string $email = null; + + // RG-1.03 — distributor / broker auto-references (mutuellement exclusives, + // contrainte CHECK en base). + #[ORM\ManyToOne(targetEntity: Client::class)] + #[ORM\JoinColumn(name: 'distributor_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + #[Groups(['client:read', 'client:write:main'])] + private ?Client $distributor = null; + + #[ORM\ManyToOne(targetEntity: Client::class)] + #[ORM\JoinColumn(name: 'broker_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + #[Groups(['client:read', 'client:write:main'])] + private ?Client $broker = null; + + #[ORM\Column(name: 'triage_service', options: ['default' => false])] + #[Groups(['client:read', 'client:write:main'])] + private bool $triageService = false; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: Category::class)] + #[ORM\JoinTable(name: 'client_category')] + #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')] + #[Groups(['client:read', 'client:write:main'])] + private Collection $categories; + + // === Onglet Information === + #[ORM\Column(type: 'text', nullable: true)] + #[Groups(['client:read', 'client:write:information'])] + private ?string $description = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['client:read', 'client:write:information'])] + private ?string $competitors = null; + + #[ORM\Column(type: 'date_immutable', nullable: true)] + #[Groups(['client:read', 'client:write:information'])] + private ?DateTimeImmutable $foundedAt = null; + + #[ORM\Column(nullable: true)] + #[Assert\PositiveOrZero] + #[Groups(['client:read', 'client:write:information'])] + private ?int $employeesCount = null; + + #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] + #[Groups(['client:read', 'client:write:information'])] + private ?string $revenueAmount = null; + + #[ORM\Column(length: 120, nullable: true)] + #[Groups(['client:read', 'client:write:information'])] + private ?string $directorName = null; + + #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] + #[Groups(['client:read', 'client:write:information'])] + private ?string $profitAmount = null; + + // === Onglet Comptabilité === + // Lecture conditionnee via group 'client:read:accounting' (le Provider + // l'ajoute dynamiquement si l'user a la permission accounting.view). + // Ecriture conditionnee via group 'client:write:accounting' (le Processor + // exige is_granted('commercial.clients.accounting.manage') si ce groupe + // est present dans le payload). + #[ORM\Column(length: 20, nullable: true)] + #[Groups(['client:read:accounting', 'client:write:accounting'])] + private ?string $siren = null; + + #[ORM\Column(length: 40, nullable: true)] + #[Groups(['client:read:accounting', 'client:write:accounting'])] + private ?string $accountNumber = null; + + #[ORM\ManyToOne(targetEntity: TvaMode::class)] + #[ORM\JoinColumn(name: 'tva_mode_id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['client:read:accounting', 'client:write:accounting'])] + private ?TvaMode $tvaMode = null; + + #[ORM\Column(length: 40, nullable: true)] + #[Groups(['client:read:accounting', 'client:write:accounting'])] + private ?string $nTva = null; + + #[ORM\ManyToOne(targetEntity: PaymentDelay::class)] + #[ORM\JoinColumn(name: 'payment_delay_id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['client:read:accounting', 'client:write:accounting'])] + private ?PaymentDelay $paymentDelay = null; + + #[ORM\ManyToOne(targetEntity: PaymentType::class)] + #[ORM\JoinColumn(name: 'payment_type_id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['client:read:accounting', 'client:write:accounting'])] + private ?PaymentType $paymentType = null; + + #[ORM\ManyToOne(targetEntity: Bank::class)] + #[ORM\JoinColumn(name: 'bank_id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['client:read:accounting', 'client:write:accounting'])] + private ?Bank $bank = null; + + // === Sous-collections (exposées via sous-ressources API Platform dédiées) === + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $contacts; + + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $addresses; + + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $ribs; + + // === Archive / Soft delete === + #[ORM\Column(name: 'is_archived', options: ['default' => false])] + #[Groups(['client:read', 'client:write:archive'])] + private bool $isArchived = false; + + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + #[Groups(['client:read'])] + private ?DateTimeImmutable $archivedAt = null; + + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + private ?DateTimeImmutable $deletedAt = null; + + public function __construct() + { + $this->categories = new ArrayCollection(); + $this->contacts = new ArrayCollection(); + $this->addresses = new ArrayCollection(); + $this->ribs = new ArrayCollection(); + } + + // Getters / setters omis pour la lisibilite — pattern Starseed standard. +} +``` + +### 3.5 Squelettes des autres entités + +**`ClientContact`**, **`ClientAddress`**, **`ClientRib`** : même pattern (`#[Auditable]`, `TimestampableBlamableTrait`, FK `client_id`). `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=` (filtre par type de catégorie via `SearchFilter`) + - `search=` (recherche fuzzy sur companyName + lastName + email) +- **Tri par défaut** : `companyName ASC` +- **Pagination** : front via `` (volumétrie cible faible). Pas de pagination serveur au M1. +- **Réponse 200** (JSON-LD Hydra) : items avec champs `client:read` UNIQUEMENT (pas les champs `client:read:accounting` sauf si l'user a la permission `accounting.view`). +- **Codes** : `200` / `401` / `403` + +### 4.2 `GET /api/clients/{id}` — Détail + +- **Security** : `is_granted('commercial.clients.view')` +- **Comportement** : retourne le client + ses contacts + ses adresses + ses RIBs. Les champs `client:read:accounting` sont inclus uniquement si l'user a la permission `commercial.clients.accounting.view`. +- **Codes** : `200` / `404` / `401` / `403` + +### 4.3 `POST /api/clients` — Création (formulaire principal) + +- **Security** : `is_granted('commercial.clients.manage')` +- **Body** (groupe `client:write:main` uniquement) : +```json +{ + "companyName": "ACME SAS", + "firstName": "Jean", + "lastName": "Dupont", + "phonePrimary": "0612345678", + "email": "jean.dupont@acme.fr", + "categories": ["/api/categories/3", "/api/categories/7"], + "distributor": null, + "broker": null, + "triageService": false +} +``` +- **Réponse 201** : le client créé avec son `id`. Le front enchaîne ensuite les PATCH par onglet. +- **Codes** : + - `201` / `400` / `401` / `403` + - `409 Conflict` si doublon (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 » : +```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` + - 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 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 dupont` → `Jean Dupont`. +- **RG-1.20** : Les champs téléphone (`phonePrimary`, `phoneSecondary` sur `Client`, et idem sur `ClientContact`) sont **normalisés à chiffres uniquement** côté serveur (`preg_replace('/\D+/', '', $v)`). Stockage : `0612345678`. Le **format affichage `XX XX XX XX XX`** est de la responsabilité du front via un filter Vue dédié (cf. spec-front). +- **RG-1.21** : `email` (`Client.email`, `ClientAddress.billingEmail`, `ClientContact.email`) est **lowercase** intégralement côté serveur (`mb_strtolower(trim($v), 'UTF-8')`). + +### Archivage + +- **RG-1.22** : Un PATCH avec `{ "isArchived": true }` exige la permission `commercial.clients.archive`. Pose `isArchived = true` ET `archivedAt = now()`. Aucun autre champ ne peut être modifié dans la même requête. +- **RG-1.23** : Un PATCH avec `{ "isArchived": false }` (restauration) exige la même permission. Pose `isArchived = false` ET `archivedAt = null`. Si la restauration entre en conflit avec une unicité (un autre client actif a pris le SIREN / email / nom entre-temps) → 409 « Restauration impossible : un autre client a pris le SIREN/email/nom entre-temps ». + +### Liste / Détail + +- **RG-1.24** : `GET /api/clients` exclut par défaut les clients archivés (`is_archived = TRUE`) ET soft-deletés (`deleted_at IS NOT NULL`). Filtré dans le `ClientProvider`. +- **RG-1.25** : Avec `?includeArchived=true`, les archivés sont inclus dans la liste. Les soft-deletés restent exclus au M1 (HP M2). +- **RG-1.26** : Tri par défaut côté serveur : `companyName ASC`. + +### Timestampable + Blamable + +- **RG-1.27** : Pattern Shared standard (cf. RG-1.15 à RG-1.17 du M0). Tous les actes (POST/PATCH/archive/RIB/contact/adresse) tracent `updatedAt` + `updatedBy`. `createdAt` + `createdBy` posés au POST initial. + +## 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 `` : `` + bouton « + Ajouter » → bascule sur `/commercial/clients/new` +- [ ] Composant `` : formulaire principal + onglets séquentiels, désactivation onglet suivant tant qu'actuel pas validé +- [ ] Permissions : Compta accède à `/commercial/clients/{id}` mais l'onglet Comptabilité est en lecture seule ; Commerciale ne voit pas l'onglet Comptabilité du tout + +### 8.3 Tests E2E + +**Non prévus au M1.** Règle ABSOLUE Starseed n°7. Extension des personas existants (Bureau / Compta / Commerciale) pour ajouter les permissions M1 — cf. § 5.3. + +## 9. Hors-périmètre (HP) + +- **HP-M2-1** : **DELETE / soft delete d'un client.** Au M1, seul l'archivage est exposé. La colonne `deleted_at` existe mais aucune API ne la touche. À ouvrir en M2 si besoin métier (purge RGPD, doublon créé par erreur, etc.). +- **HP-M2-2** : **CRUD admin des référentiels comptables** (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`). Au M1, seed initial via migration. Pas d'écran admin pour les éditer. +- **HP-M2-3** : **CRUD admin de `CategoryType`** (toujours HP du M0 — le M1 seed seulement 4 types). +- **HP-M2-4** : **Onglet Transport** (front + back). Au M1, placeholder blanc. +- **HP-M2-5** : **Onglet Statistiques** (graphes commande / panier moyen / etc.). Placeholder blanc. +- **HP-M2-6** : **Onglet Rapports** (PDF généré). Placeholder blanc. +- **HP-M2-7** : **Onglet Échanges** (timeline d'emails / appels). Placeholder blanc. +- **HP-M2-8** : **Restauration d'un client soft-deleted** (post-DELETE M2). Pas pertinent au M1. +- **HP-M2-9** : **Export CSV** (en plus du XLSX). À étudier si besoin métier. +- **HP-M2-10** : **Compta en édition de l'onglet Comptabilité** (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 + +- 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. diff --git a/docs/specs/M1-clients/spec-front.md b/docs/specs/M1-clients/spec-front.md new file mode 100644 index 0000000..64194e5 --- /dev/null +++ b/docs/specs/M1-clients/spec-front.md @@ -0,0 +1,289 @@ +--- +# === IDENTITÉ === +module: M1 +nom: "Répertoire clients" +ecran: repertoire-clients +owner_spec: Matthieu +backup_spec: Tristan +version: V0 +date_redaction: 2026-05-28 + +# === LIENS === +maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898" +regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.04, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13, RG-1.14, RG-1.15, RG-1.16, RG-1.17, RG-1.18, RG-1.19, RG-1.20, RG-1.21] +roles: [Admin, Bureau, Compta, Commerciale, Usine] +lien_spec_back: ./spec-back.md + +# === VALIDATION CLIENT #1 === +client_validation_1: + statut: validee + date: 2026-05-22 + canal: ecrit + valide_par: "Matthieu (CP MALIO) — validation implicite, périmètre projet" + resume: "Module 1 — Répertoire clients. Page d'entrée Commercial. Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Information / Contact / Adresse / Comptabilité (Transport, Statistiques, Rapports, Échanges = placeholders blancs)." + trace_archivee: "uploads/4a1b026f-M1-reportoire-clients.docx (V0 d'origine .docx)" + +# === LIEN LESSTIME === +lesstime_taskgroup_id: 0 +lesstime_project_id: 6 +statut_global: en_redaction +--- + +# Module 1 — Répertoire clients (V0 front) + +> **Origine** : spec front V0 livrée le 22/05/2026 (`M1-reportoire-clients.docx`). Restitution Markdown pour intégration au workflow MALIO. Le contenu original n'est pas modifié — toute précision et toute décision (en particulier côté back) vit dans [`spec-back.md`](./spec-back.md). + +## But + +Permettre aux utilisateurs Starseed (selon rôle) de gérer le **répertoire des clients** de l'organisation : consultation, création, modification, archivage. Cette page est la **porte d'entrée du module Commercial**. + +## Accès + +- **Depuis** : menu principal → section **Commercial** → entrée « Répertoire clients » +- **Rôles autorisés** : + +| Rôle | Consultation | Création / Modification | Archivage | +|---|---|---|---| +| **Admin** | ✅ Tout | ✅ Tout | ✅ | +| **Bureau** | ✅ Tout | ✅ Tout sauf onglet Comptabilité | ❌ | +| **Compta** | ✅ Tout | ❌ (lecture seule) | ❌ | +| **Commerciale** | ✅ Tout sauf Comptabilité | ✅ Tout sauf Comptabilité | ❌ | +| **Usine** | ❌ | ❌ | ❌ | + +> **⚠ Décision validée par Tristan (28/05/2026)** : le rôle **Compta est en lecture seule** sur l'ensemble du module clients, y compris l'onglet Comptabilité. Le tableau d'origine du `.docx` indiquait « Compta = Ajout / Modification : Onglet Comptabilité uniquement » — cette ligne est **invalidée** par cette spec. Si un besoin métier d'édition apparaît plus tard, une décision archi dédiée sera prise (cf. HP-X de [`spec-back.md`](./spec-back.md)). + +## Navigation + +L'écran est la page d'entrée du module **Commercial**. Titre : « **Répertoire clients** ». + +- Affichage principal : un **datatable** listant tous les clients **actifs** de l'organisation (les clients archivés sont masqués par défaut — filtre UI dédié pour les voir). +- **Clic sur une ligne** → bascule sur l'écran **Consultation client** (page dédiée, pas un drawer — cf. maquette Figma). +- **Bouton « + Ajouter »** (en haut à droite) → bascule sur l'écran **Ajouter un client**. +- **Bouton « Exporter »** (en haut à droite) → télécharge un **fichier XLSX** des clients **affichés** (cf. filtre actif). Format détaillé dans [`spec-back.md` § Export](./spec-back.md). + +## Datatable du Répertoire + +Composant : ``. Colonnes (à raffiner avec Tristan en revue maquette) : + +| Colonne | Source | Tri | +|---|---|---| +| **Nom entreprise** | `client.companyName` | ASC par défaut | +| **Contact principal** | `firstName + lastName` | Oui | +| **Téléphone principal** | `phonePrimary` (formaté `XX XX XX XX XX`) | Non | +| **Email principal** | `email` | Oui | +| **Catégories** | liste des codes catégories séparés par `,` | Non | +| **Site(s)** | sites rattachés à au moins une adresse (badges colorés) | Non | + +> **Filtre archivés** : toggle UI en haut du datatable. Désactivé par défaut. État local (pas dans l'URL — cf. règle ABSOLUE Starseed n°6). + +> **Pagination** : front via `` (volumétrie cible faible — quelques centaines). Tri serveur `companyName ASC` par défaut. + +## Écran « Ajouter un client » + +Création par **onglets successifs avec validation incrémentale** : pour pouvoir passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant**, et les champs de l'onglet validé passent en lecture seule + bouton « Valider » désactivé (disabled). + +### Formulaire principal (pré-onglets) + +C'est le 1er bloc à remplir. Sans validation de ce formulaire, les onglets ne sont pas accessibles. + +| Champ | Type composant | Obligatoire | Règle | +|---|---|---|---| +| **Nom du client (Entreprise)** | `` | Oui | RG-1.18 (normalisation UPPERCASE serveur) | +| **Nom du contact principal** | `` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) | +| **Prénom du contact principal** | `` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) | +| **Catégorie** | `` (multi) | Oui | Liste des `Category` de l'API ; M2M Client ↔ Category | +| **Téléphone principal** | `` (masque tel) | Oui | RG-1.02 + RG-1.20 (format `XX XX XX XX XX`) | +| **Téléphone secondaire** | `` (masque tel) | Non | Apparaît au clic sur le bouton `+` (RG-1.02). Max 2 — bouton `+` disparaît une fois rempli. | +| **Email** | `` type email | Oui | RG-1.21 (lowercase) | +| **Distributeur / Courtier** | `` | Non | Valeurs : `Dépend du distributeur` / `Dépend du courtier` / `Aucun`. RG-1.03 conditionne les 2 champs suivants. | +| **Nom du distributeur** | `` | Conditionnel | Visible si « Dépend du distributeur ». Liste = clients ayant ≥ 1 catégorie de type `DISTRIBUTEUR`. RG-1.03. | +| **Nom du courtier** | `` | Conditionnel | Visible si « Dépend du courtier ». Liste = clients ayant ≥ 1 catégorie de type `COURTIER`. RG-1.03. | +| **Prestation de triage** | `` | Non | — | + +**Action** : « Valider » (``) → POST `/api/clients` ([`spec-back.md` § 4.3](./spec-back.md)). Si succès, on passe automatiquement à l'onglet « Information ». + +### Onglet « Information » + +Saisir les informations de l'entreprise. + +| Champ | Type | Obligatoire | Règle | +|---|---|---|---| +| **Description** | `` | Conditionnel | RG-1.04 (obligatoire pour rôle Commerciale) | +| **Concurrents** | `` | Conditionnel | RG-1.04 | +| **Date de création** (de l'entreprise) | `` (exception Malio — pas de composant date couvert) | Conditionnel | RG-1.04 | +| **Nombre de salariés** | `` | Conditionnel | RG-1.04 | +| **CA €** | `` | Conditionnel | RG-1.04 | +| **Dirigeant** | `` | Conditionnel | RG-1.04 | +| **Résultat €** | `` | Conditionnel | RG-1.04 | + +**Action** : « Valider » → PATCH partiel `/api/clients/{id}` (groupe `client:write:information`). + +### Onglet « Contact » + +Saisir un ou plusieurs contacts associés au client. Le 1er bloc est **pré-rempli** depuis les champs du formulaire principal (Nom, Prénom, Téléphone, Email — édition autorisée). + +**Bloc Contact** : + +| Champ | Type | Obligatoire | Règle | +|---|---|---|---| +| **Nom** | `` | Conditionnel | RG-1.05 + RG-1.19 (Capitalize) | +| **Prénom** | `` | Conditionnel | RG-1.05 + RG-1.19 (Capitalize) | +| **Fonction** | `` | Non | — | +| **Téléphone** (x1, +1 possible) | `` | Non | RG-1.20 (format) | +| **Email** | `` type email | Non | RG-1.21 (lowercase) | + +**RG-1.14 (renforcement validée par Tristan le 28/05)** : **au moins 1 bloc Contact valide** (au moins Nom OU Prénom rempli) est obligatoire pour valider l'onglet. Donc l'onglet Contact ne peut pas être finalisé vide. + +**Actions** : +- « + Nouveau contact » : ajoute un bloc. Bouton **désactivé tant que le bloc précédent n'a pas Prénom OU Nom rempli** (RG-1.05). +- « Supprimer » (icône) sur un bloc : modal de confirmation (`` Annuler / Confirmer). Si Oui → suppression du bloc. +- « Valider » → PATCH `/api/clients/{id}/contacts` (création/mise à jour de la collection). + +### Onglet « Adresse » + +Saisir une ou plusieurs adresses du client, rattachées à un ou plusieurs sites Starseed (Châtellerault 86 / Saint-Jean 17 / Pommevic 82) et à des contacts. + +**Bloc Adresse** : + +| Champ | Type | Obligatoire | Règle | +|---|---|---|---| +| **Prospect** | `` | Non | RG-1.06 — masque Adresse de livraison + Facturation si coché | +| **Adresse de livraison** | `` | Non | RG-1.07 — masque Prospect si coché | +| **Facturation** | `` | Non | RG-1.08 — masque Prospect si coché ; affiche le champ Email (RG-1.11) | +| **Catégorie** | `` (multi) | Oui | Liste des `Category` | +| **Pays** | `` | Oui | Préremplie « France » | +| **Code postal** | `` (masque numérique) | Oui | RG-1.09 — déclenche autocomplete ville via BAN | +| **Ville** | `` | Oui | RG-1.09 — alimentée par api-adresse.data.gouv.fr suivant le CP | +| **Adresse** | `` (saisie assistée) | Oui | RG-1.09 — autocomplete BAN | +| **Adresse complémentaire** | `` | Non | — | +| **Sites Starseed** | `` (multi-checkbox 86 / 17 / 82) | Oui | RG-1.10 — ≥ 1 site obligatoire | +| **Contact(s) rattaché(s)** | `` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact | +| **Email (facturation)** | `` type email | Conditionnel | RG-1.11 — visible/obligatoire uniquement si « Facturation » coché | + +**Actions** : +- « + Nouvelle Adresse » : ajoute un bloc identique. +- « Supprimer » : modal de confirmation puis suppression. +- « Valider » → PATCH `/api/clients/{id}/addresses`. + +### Onglet « Transport » + +🚧 **Placeholder blanc au M1.** Frame vide. Aucun champ. Aucun bouton de validation. L'utilisateur passe automatiquement à l'onglet suivant. **Pas de mention « En cours »** — c'est juste blanc (décision Tristan 28/05). + +### Onglet « Comptabilité » + +⚠ **Accessible uniquement aux rôles avec `commercial.clients.accounting.manage`** (Admin seul au M1). Bureau et Commerciale ne voient pas l'onglet. Compta voit l'onglet **en lecture seule** (cf. décision Compta lecture seule). + +**Champs comptables** : + +| Champ | Type | Obligatoire | Règle | +|---|---|---|---| +| **SIREN** | `` (masque 9 chiffres) | Oui | RG-1.15 (unicité) | +| **Numéro de compte** | `` | Oui | — | +| **Mode de TVA** | `` | Oui | Liste depuis `/api/tva_modes` | +| **N° de TVA** | `` | Oui | — | +| **Délai de règlement** | `` | Oui | Liste depuis `/api/payment_delays` | +| **Type de règlement** | `` | Oui | Liste depuis `/api/payment_types` | +| **Banque** | `` | Conditionnel | RG-1.12 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks`. | + +**Bloc RIB** (0..n blocs, présence obligatoire conditionnée par RG-1.13) : + +| Champ | Type | Obligatoire | Règle | +|---|---|---|---| +| **Libellé** | `` | Oui (si LCR) | RG-1.13 | +| **BIC** | `` | Oui (si LCR) | RG-1.13 — `#[AuditIgnore]` (champ sensible) | +| **IBAN** | `` | Oui (si LCR) | RG-1.13 — `#[AuditIgnore]` (champ sensible) | + +**Actions** : +- « + RIB » : ajoute un bloc. +- « Supprimer » (icône) : modal de confirmation. +- « Valider » → PATCH `/api/clients/{id}/accounting`. + +### Onglets « Statistiques » / « Rapports » / « Échanges » + +🚧 **Placeholders blancs au M1.** Mêmes règles que Transport (frames vides, pas de validation). + +## Écran « Consultation client » + +Tous les champs en **lecture seule**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans bouton `+` pour ajouter des blocs Contact / Adresse / RIB. + +- **Flèche retour** (à gauche) → revient au Répertoire. +- **Bouton « Modifier »** (à droite, visible si l'utilisateur a la permission `commercial.clients.manage`) → bascule sur l'écran Modification. +- **Bouton « Archiver »** (à droite, visible **uniquement pour Admin** via permission `commercial.clients.archive`) → ouvre une modal de confirmation, puis PATCH `/api/clients/{id}` `{ "isArchived": true }`. Le client passe en archivé (cf. flag `is_archived`). + +> Le client archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé. Décision validée Tristan 28/05. + +### Onglets affichés en consultation + +Mêmes onglets qu'en création, **plus** les 4 placeholders blancs. L'utilisateur navigue librement entre les onglets (pas de séquence forcée en consultation). + +## Écran « Modification client » + +Comportement identique à l'écran Ajouter sauf : +- **Pas de formulaire principal** (les champs principaux sont édités via les onglets correspondants). +- Les champs sont **pré-remplis** avec les valeurs actuelles. +- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel). +- Les onglets pour lesquels l'utilisateur n'a **pas** la permission `manage` restent en lecture seule (pas de bouton Valider, pas d'icône suppression de bloc). +- Les onglets placeholders restent inaccessibles à l'édition (blancs). + +## Composants UI à utiliser (`@malio/layer-ui`) + +- **Datatable** : `` (Répertoire) +- **Input texte** : `` +- **Input numérique** : `` +- **Input montant** : `` (CA, Résultat) +- **TextArea** : `` (Description) +- **Select simple** : `` (Pays, Ville, distributeur/courtier, refs comptables) +- **Select multi (cases à cocher)** : `` (Catégorie, Sites, Contacts rattachés) +- **Checkbox** : `` (Prospect, Adresse livraison, Facturation, Prestation de triage) +- **Bouton** : ``, `` +- **Toasts** : standards via `useApi()` + +**Exceptions autorisées** (à commenter `// TODO migrer quand Malio couvre`) : +- `` pour « Date de création » (composant `MalioDate` non couvert) +- Modal de confirmation : composant à confirmer côté équipe front (probablement `` ou un wrapper à créer dans `frontend/shared/`) + +## Règles de formatage et normalisation + +Le serveur normalise systématiquement (cf. RG-1.18 à RG-1.21 dans [`spec-back.md`](./spec-back.md)) : + +| Champ | Normalisation serveur | Affichage front | +|---|---|---| +| Nom entreprise (`companyName`) | UPPERCASE intégral | UPPERCASE | +| Nom + Prénom contact | Capitalize (1ère lettre majuscule + reste minuscule) | identique | +| Téléphone (`phonePrimary`, `phoneSecondary`, contact phones) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` à l'affichage (filter Vue) | +| Email | lowercase intégral | identique | + +> **Le front ne fait pas la normalisation** — il envoie la valeur saisie, le serveur normalise puis renvoie la valeur normalisée. L'UI affiche immédiatement la valeur normalisée renvoyée par l'API. Cohérent avec le pattern `useApi()`. + +## API adresse postale + +Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse.data.gouv.fr** (Base Adresse Nationale, gratuite, française). + +- Composable dédié `useAddressAutocomplete()` (à créer en M1). +- Appel HTTP **direct depuis le front** (CORS OK), pas de proxy back. +- Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions adresse. +- Cas dégradé : si l'API ne répond pas (offline, timeout), le champ Ville devient un `` libre éditable + toast d'avertissement. Validation serveur acceptera la saisie libre. + +## Points laissés ouverts par la V0 (résolus côté back) + +| # | Zone d'ombre V0 | Résolution (cf. `spec-back.md`) | +|---|---|---| +| 1 | Catégorie en multi-select non clarifiée (1 ou n par client) | **M2M `client_category`** validée. CategoryType seedé avec `DISTRIBUTEUR`, `COURTIER`, `SECTEUR`, `AUTRE` (HP-3 du M0 levé). | +| 2 | Distributeur / Courtier : liste de quoi ? | **Auto-référence Client** via 2 FK nullables `distributor_id` et `broker_id` (cf. RG-1.03). Une seule des deux est remplie à la fois. | +| 3 | Onglet « Comptabilité » : qui édite ? | **Admin uniquement au M1.** Compta lecture seule (décision validée par Tristan 28/05). Bureau / Commerciale ne voient pas l'onglet. | +| 4 | Workflow par onglet | **Sauvegarde incrémentale**. POST formulaire principal crée le `Client` (status implicite « actif »). Chaque onglet validé = PATCH partiel par groupe de sérialisation dédié. Pas d'état « draft ». | +| 5 | Onglets « À venir » | **Placeholders blancs** (frames vides, pas de message). Ré-activables sans rebuild quand les modules associés arriveront. | +| 6 | Archive vs soft delete | **Flag `is_archived` séparé de `deleted_at`**. Archive ≠ delete : un client archivé est masqué par défaut mais reste en BDD éditable (Admin seul). Filtres UI distincts. Soft delete = HP M2. | +| 7 | Unicité métier | **SIREN, Nom entreprise, Email principal — tous uniques parmi non-archivés.** Index partiels Postgres. Tentative de doublon → 409 Conflict. | +| 8 | Téléphones (max 2) | **2 colonnes plates** `phone_primary` + `phone_secondary`. Pas de table séparée. | +| 9 | API code postal | **api-adresse.data.gouv.fr** (BAN). Appel direct front via composable dédié. Cas dégradé : saisie libre + toast. | +| 10 | Référentiels comptables | **4 entités CRUD-ables** (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`) seedées au M1, CRUD admin futur (HP-M2). | +| 11 | Format de l'export | **XLSX uniquement** au M1. CSV à étudier en HP. | + +--- + +## 📦 Tickets Lesstime générés + +**TaskGroup Lesstime** : à créer — `M1 — Répertoire clients` (projet `ERP / Starseed`, projectId=6). + +> Détail complet, table des tickets et action manuelle dans Lesstime → voir [`spec-back.md § Tickets Lesstime générés`](./spec-back.md#-tickets-lesstime-générés).