docs(commercial) : refonte contact — suppression du contact inline (specs M1 + M2) (#54)
Auto Tag Develop / tag (push) Successful in 6s
Auto Tag Develop / tag (push) Successful in 6s
Acte la décision refonte-contact dans les specs : le contact principal inline (firstName/lastName/phonePrimary/phoneSecondary/email) est retiré des entités tiers (Client, Supplier). Les contacts vivent uniquement dans ClientContact / SupplierContact (onglet Contacts). Garantie « >=1 contact nommé » préservée par RG-1.05/1.14 (M1) et RG-2.04/2.13 (M2). - M1 (spec-back/spec-front/cahier) : modèle Client sans contact inline ; RG-1.01/1.02 supprimées ; D1 (recherche) / D2 (export) décrites ; version V1. - M2 (spec-back/spec-front) : FICHIERS NOUVEAUX (non versionnés sur develop), introduits déjà corrigés (Supplier sans contact inline, RG-2.01/2.02 supprimées) ; version V0.2. - docs/specs/M1-clients/refonte-contact/ : décision (README) + tickets (M1 back/front/specs, M2 specs) + prompts + amendement des tickets M2. Lesstime : tâches #103 (M1 back), #104 (M1 front), #105 (M1 specs), #106 (M2 specs) ; tickets M2 #85-#97 amendés. --------- Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #54 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #54.
This commit is contained in:
@@ -5,8 +5,11 @@ nom: "Répertoire clients"
|
||||
ecran: repertoire-clients
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0
|
||||
version: V1
|
||||
date_redaction: 2026-05-28
|
||||
# Historique : V1 (2026-06-03) — Refonte contact : suppression du contact principal inline
|
||||
# du Client (firstName/lastName/phonePrimary/phoneSecondary/email retirés de la table client).
|
||||
# Les contacts vivent uniquement dans ClientContact. Cf. docs/specs/M1-clients/refonte-contact/README.md
|
||||
|
||||
# === LIENS ===
|
||||
spec_front: ./spec-front.md
|
||||
@@ -203,11 +206,11 @@ Le **formatage `XX XX XX XX XX`** est fait à l'affichage côté front (filter V
|
||||
| | +-----------------------+ | (Catalog) |
|
||||
| id (PK) | +--------------+
|
||||
| company_name |
|
||||
| first_name | +-----------------------+ +--------------+
|
||||
| last_name |--1:n-->| client_contact | | site |
|
||||
| phone_primary | +-----------------------+ | (Sites) |
|
||||
| phone_secondary | +--------------+
|
||||
| email | +-----------------------+ ^
|
||||
| (contact inline | +-----------------------+ +--------------+
|
||||
| retiré V1 — |--1:n-->| client_contact | | site |
|
||||
| firstName, | +-----------------------+ | (Sites) |
|
||||
| lastName, phones,| +--------------+
|
||||
| email) | +-----------------------+ ^
|
||||
| distributor_id |--1:n-->| client_address |--n:m---------+
|
||||
| broker_id | +-----------------------+
|
||||
| triage_service | |
|
||||
@@ -302,11 +305,8 @@ 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,
|
||||
-- Contact inline retiré (V1, refonte-contact) : first_name / last_name / phone_primary /
|
||||
-- phone_secondary / email vivent désormais uniquement dans client_contact (onglet Contacts).
|
||||
distributor_id INT REFERENCES client(id) ON DELETE SET NULL,
|
||||
broker_id INT REFERENCES client(id) ON DELETE SET NULL,
|
||||
triage_service BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
@@ -580,32 +580,9 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
#[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;
|
||||
// Contact inline retiré (V1, refonte-contact) : firstName / lastName / phonePrimary /
|
||||
// phoneSecondary / email ne sont plus portés par Client — ils vivent dans ClientContact
|
||||
// (onglet Contacts). La garantie « ≥ 1 contact nommé » est portée par RG-1.05 + RG-1.14.
|
||||
|
||||
// RG-1.03 — distributor / broker auto-references (mutuellement exclusives,
|
||||
// contrainte CHECK en base).
|
||||
@@ -749,7 +726,7 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
- **Query params** :
|
||||
- `includeArchived=true|false` (default `false`)
|
||||
- `categoryCode=<code>` (filtre les clients ayant ≥ 1 `Category` de ce code stable — ERP-78 ; ex. `DISTRIBUTEUR`, `COURTIER`)
|
||||
- `search=<text>` (recherche fuzzy sur companyName + lastName + email)
|
||||
- `search=<text>` (recherche fuzzy sur companyName + contacts liés `client_contact` (firstName / lastName / email) via LEFT JOIN groupé par `client.id` — décision D1, refonte-contact)
|
||||
- **Tri par défaut** : `companyName ASC`
|
||||
- **Pagination** : front via `<MalioDataTable>` (volumétrie cible faible). Pas de pagination serveur au M1.
|
||||
- **Réponse 200** (JSON-LD Hydra) : items avec champs `client:read` UNIQUEMENT (pas les champs `client:read:accounting` sauf si l'user a la permission `accounting.view`).
|
||||
@@ -768,10 +745,6 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
```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,
|
||||
@@ -783,7 +756,7 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
- `201` / `400` / `401` / `403`
|
||||
- `409 Conflict` si doublon de nom de société (`companyName` — RG-1.16). SIREN et email ne sont pas uniques (cf. Q4, § 2.4).
|
||||
- `422 Unprocessable Entity` :
|
||||
- RG-1.01 : ni firstName ni lastName
|
||||
- (RG-1.01 supprimée V1 — la complétude du contact est portée par l'onglet Contacts : RG-1.05 / RG-1.14)
|
||||
- RG-1.03 : distributor + broker remplis simultanément
|
||||
- Catégories vides (Assert\Count min=1)
|
||||
|
||||
@@ -885,8 +858,8 @@ Cf. § 2.6. Pattern Shared standard.
|
||||
|
||||
### 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.01**~~ _(SUPPRIMÉE — V1, 2026-06-03, refonte-contact)_ : le contact principal inline est retiré du `Client`. La garantie « au moins un contact nommé » est désormais portée par **RG-1.05** (bloc Contact valide) + **RG-1.14** (≥ 1 bloc Contact) sur `ClientContact`.
|
||||
- ~~**RG-1.02**~~ _(SUPPRIMÉE du Client — V1, refonte-contact)_ : plus de téléphones inline sur le `Client`. Le « maximum 2 téléphones » reste applicable aux blocs `ClientContact` (normalisation RG-1.20).
|
||||
- **RG-1.03** : Les champs `distributor` et `broker` sont **mutuellement exclusifs** (au plus une seule des deux est renseignée). Tentative d'envoyer les deux → 422. Contrainte CHECK en base également : `NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)`. Un `distributor` référencé doit porter une **`Category` de code `DISTRIBUTEUR`** ; un `broker` une **`Category` de code `COURTIER`** — sinon 422. _(Refonte ERP-78 : le filtrage se fait sur le `code` de la `Category`, plus sur le type — `ClientProcessor::hasCategoryCode`.)_ La liste front de `distributor` = clients ayant une catégorie de code `DISTRIBUTEUR`, via `GET /api/clients?categoryCode=DISTRIBUTEUR` ; idem `broker` avec `COURTIER`.
|
||||
|
||||
### Onglet Information
|
||||
@@ -929,9 +902,9 @@ Cf. § 2.6. Pattern Shared standard.
|
||||
### 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')`).
|
||||
- **RG-1.19** : `firstName`, `lastName` (sur `ClientContact` ; scope `Client` retiré en V1) sont **capitalize**-és serveur (`mb_convert_case(trim($v), MB_CASE_TITLE, 'UTF-8')`). Exemple : `JEAN dupont` → `Jean Dupont`.
|
||||
- **RG-1.20** : Les champs téléphone (`phonePrimary`, `phoneSecondary` sur `ClientContact` ; scope `Client` retiré en V1) sont **normalisés à chiffres uniquement** côté serveur (`preg_replace('/\D+/', '', $v)`). Stockage : `0612345678`. Le **format affichage `XX XX XX XX XX`** est de la responsabilité du front via un filter Vue dédié (cf. spec-front).
|
||||
- **RG-1.21** : `email` (`ClientAddress.billingEmail`, `ClientContact.email` ; `Client.email` retiré en V1) est **lowercase** intégralement côté serveur (`mb_strtolower(trim($v), 'UTF-8')`).
|
||||
|
||||
### Archivage
|
||||
|
||||
@@ -960,8 +933,8 @@ Cf. § 2.6. Pattern Shared standard.
|
||||
|
||||
### 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.01~~ _(supprimée V1)_ : la complétude du contact est couverte par RG-1.05 / RG-1.14 sur `ClientContact`
|
||||
- [ ] ~~RG-1.02~~ _(supprimée du Client V1)_ : plus de téléphones inline sur le Client (téléphones testés sur `ClientContact`)
|
||||
- [ ] **RG-1.03** : POST avec distributor ET broker → 422 ; POST distributor seul → 201
|
||||
- [ ] **RG-1.03** : POST distributor référençant un client SANS catégorie de code DISTRIBUTEUR → 422 (validation custom `ClientProcessor::hasCategoryCode`)
|
||||
- [ ] **RG-1.04** : PATCH onglet Information par un user Commerciale avec champs incomplets → 422 ; même PATCH par Admin → 200
|
||||
@@ -975,9 +948,9 @@ Cf. § 2.6. Pattern Shared standard.
|
||||
- [ ] **RG-1.14** : front-driven uniquement, pas de test back
|
||||
- [ ] **RG-1.16** : POST avec `companyName` déjà pris → 409 ; POST avec même `companyName` après archivage de l'ancien → 201. SIREN et email dupliqués → 201 (plus d'unicité — RG-1.15/1.17 supprimées, Q4).
|
||||
- [ ] **RG-1.18** : POST `companyName="acme sas"` → BDD persiste `"ACME SAS"`
|
||||
- [ ] **RG-1.19** : POST `firstName="JEAN"`, `lastName="dupont"` → persiste `"Jean"`, `"Dupont"`
|
||||
- [ ] **RG-1.20** : POST `phonePrimary="06.12.34.56.78"` → persiste `"0612345678"`
|
||||
- [ ] **RG-1.21** : POST `email="Jean.DUPONT@ACME.FR"` → persiste `"jean.dupont@acme.fr"`
|
||||
- [ ] **RG-1.19** : POST `firstName="JEAN"`, `lastName="dupont"` (via un bloc `ClientContact`) → persiste `"Jean"`, `"Dupont"`
|
||||
- [ ] **RG-1.20** : POST `phonePrimary="06.12.34.56.78"` (via un bloc `ClientContact`) → persiste `"0612345678"`
|
||||
- [ ] **RG-1.21** : POST `email="Jean.DUPONT@ACME.FR"` (via `ClientContact` ou `ClientAddress.billingEmail`) → persiste `"jean.dupont@acme.fr"`
|
||||
- [ ] **RG-1.22/23** : PATCH isArchived=true par Bureau (sans `archive`) → 403 ; par Admin → 200 + archivedAt rempli ; PATCH isArchived=false sur un client archivé dont le SIREN a été repris → 409
|
||||
- [ ] **RG-1.24/25** : GET liste sans flag → exclut archivés ; avec `?includeArchived=true` → inclut
|
||||
- [ ] **RG-1.26** : GET liste → tri companyName ASC
|
||||
|
||||
Reference in New Issue
Block a user