1057 lines
55 KiB
Markdown
1057 lines
55 KiB
Markdown
---
|
|
# === IDENTITÉ ===
|
|
module: M1
|
|
nom: "Répertoire clients"
|
|
ecran: repertoire-clients
|
|
owner_spec: Matthieu
|
|
backup_spec: Tristan
|
|
version: V0
|
|
date_redaction: 2026-05-28
|
|
|
|
# === LIENS ===
|
|
spec_front: ./spec-front.md
|
|
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898"
|
|
|
|
# === LIEN LESSTIME ===
|
|
lesstime_taskgroup_id: 23
|
|
lesstime_project_id: 6
|
|
statut_global: en_dev
|
|
|
|
# === DÉPENDANCES AMONT ===
|
|
depend_de:
|
|
- M0-categories # Category + CategoryType déjà mergés sur develop
|
|
- Sites # SitesModule + 3 sites seedés (86 / 17 / 82) déjà en place
|
|
- Core # User, Role, Permission, Audit, JWT déjà en place
|
|
- Shared # TimestampableBlamableTrait + Subscriber (ERP-52)
|
|
---
|
|
|
|
# Spec back — Module 1 : Répertoire clients
|
|
|
|
## 1. Contexte
|
|
|
|
Cette spec **complète et précise** la [spec front V0](./spec-front.md) (`M1-reportoire-clients.docx` du 22/05/2026) avec tout ce qui touche au back : décisions d'archi, modèle de données, migrations, API REST, RBAC, règles de gestion, tests, hors-périmètre.
|
|
|
|
**Module cible** : extension du module `Commercial` existant (`src/Module/Commercial/`). L'objectif est de fournir la première sous-section métier du Commercial (Clients), avec un pattern réutilisable plus tard pour Suppliers / Prestas.
|
|
|
|
**Dépendances déjà en place sur `develop`** :
|
|
- `Catalog` (M0) → `Category` + `CategoryType` (sera étendu par seed M1 : `DISTRIBUTEUR`, `COURTIER`, `SECTEUR`, `AUTRE`)
|
|
- `Sites` → 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82)
|
|
- `Shared` → `TimestampableBlamableTrait` + `Subscriber` (ERP-52)
|
|
- `Core` → User, Role, Permission, Audit
|
|
|
|
## 2. Décisions d'archi
|
|
|
|
### 2.1 Module — Extension de `Commercial`, pas un module dédié
|
|
|
|
Le client M1 vit sous `src/Module/Commercial/` (déjà existant et déclaré). Pas de nouveau module `Clients`. Rationale :
|
|
|
|
- Cohérence avec le pattern MALIO : Commercial = couche **Tiers** (Clients + Fournisseurs + Prestataires). Le module existant porte déjà `Suppliers`.
|
|
- Évite la prolifération de modules micro-scope.
|
|
- Si le scope explose à terme, on extrait via le pattern `Shared/Domain/Contract/`.
|
|
|
|
Le `CommercialModule.php` actuel est quasi vide — on l'enrichit avec la méthode statique `permissions()` (aujourd'hui absente) :
|
|
|
|
```php
|
|
<?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) :
|
|
|
|
```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
|
|
<?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) :
|
|
```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 `<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
|
|
|
|
- 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.
|