55 KiB
module, nom, ecran, owner_spec, backup_spec, version, date_redaction, spec_front, maquette_figma, lesstime_taskgroup_id, lesstime_project_id, statut_global, depend_de
| module | nom | ecran | owner_spec | backup_spec | version | date_redaction | spec_front | maquette_figma | lesstime_taskgroup_id | lesstime_project_id | statut_global | depend_de | ||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| M1 | Répertoire clients | repertoire-clients | Matthieu | Tristan | V0 | 2026-05-28 | ./spec-front.md | https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898 | 23 | 6 | en_dev |
|
Spec back — Module 1 : Répertoire clients
1. Contexte
Cette spec complète et précise la spec front V0 (M1-reportoire-clients.docx du 22/05/2026) avec tout ce qui touche au back : décisions d'archi, modèle de données, migrations, API REST, RBAC, règles de gestion, tests, hors-périmètre.
Module cible : extension du module Commercial existant (src/Module/Commercial/). L'objectif est de fournir la première sous-section métier du Commercial (Clients), avec un pattern réutilisable plus tard pour Suppliers / Prestas.
Dépendances déjà en place sur develop :
Catalog(M0) →Category+CategoryType(sera étendu par seed M1 :DISTRIBUTEUR,COURTIER,SECTEUR,AUTRE)Sites→ 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82)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
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=truepermet 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 :
LOWER(company_name)— unicité du nom d'entreprisesiren— unicité du SIRENLOWER(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]surClient,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 Informationclient:write:contacts— onglet Contact (PUT/POST/PATCH surclient_contactvia la sous-ressource)client:write:addresses— onglet Adresseclient:write:accounting— onglet Comptabilité (security separée)
Pas de state machine côté back (pas de status = draft|active). Le client est immédiatement actif dès POST réussi. Si l'utilisateur quitte avant de compléter, le client existe avec ses données minimales (formulaire principal uniquement). C'est la responsabilité du front d'enchaîner les PATCHs dans l'ordre de la séquence.
2.9 Normalisation serveur des entrées texte
Centralisée dans un ClientNormalizer (service interne, appelé par ClientProcessor et ClientContactProcessor avant validation) :
final class ClientFieldNormalizer
{
public function normalizeCompanyName(?string $value): ?string
{
return $value === null ? null : mb_strtoupper(trim($value), 'UTF-8');
}
public function normalizePersonName(?string $value): ?string
{
if ($value === null) {
return null;
}
$value = trim($value);
return $value === '' ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8');
}
public function normalizeEmail(?string $value): ?string
{
return $value === null ? null : mb_strtolower(trim($value), 'UTF-8');
}
public function normalizePhone(?string $value): ?string
{
// Persistance : chiffres uniquement (10 attendus pour FR).
return $value === null ? null : preg_replace('/\D+/', '', $value);
}
}
Le formatage XX XX XX XX XX est fait à l'affichage côté front (filter Vue formatPhoneFR()). Le back stocke 0612345678 (10 chiffres).
3. Modèle de données
3.1 Diagramme
+-------------------+ +-----------------------+ +--------------+
| client |--n:m-->| client_category |<--n:m--| category |
| | +-----------------------+ | (Catalog) |
| id (PK) | +--------------+
| company_name |
| first_name | +-----------------------+ +--------------+
| last_name |--1:n-->| client_contact | | site |
| phone_primary | +-----------------------+ | (Sites) |
| phone_secondary | +--------------+
| email | +-----------------------+ ^
| distributor_id |--1:n-->| client_address |--n:m---------+
| broker_id | +-----------------------+
| triage_service | |
| is_archived | +--n:m--+--> client_contact
| deleted_at | |
| description | +-----------------------+ +--------------+
| competitors |--1:n-->| client_rib | | tva_mode |
| founded_at | +-----------------------+ | payment_* |
| employees_count | | bank |
| revenue_amount | siren (Oui) +--------------+
| director_name | account_number (Oui)
| profit_amount | tva_mode_id (FK)
+-------------------+ n_tva
^ ^ payment_delay_id (FK)
| | payment_type_id (FK)
| +-- distributor_id bank_id (FK nullable)
+---- broker_id (auto-references via FK Client)
Particularités :
client.distributor_idetclient.broker_idsont 2 FK auto-référentes versclient.id. Une seule est non-NULL à la fois (RG-1.03).client_addressest rattachée ànsite(M2M viaclient_address_site) et ànclient_contact(M2M viaclient_address_contact).client_ribappartient à unClient(1:n).- Les colonnes Information (description, competitors, founded_at, employees_count, revenue_amount, director_name, profit_amount) sont sur Client lui-même (1:1 conceptuel — pas d'entité Information séparée).
3.2 Migration Doctrine — SQL Postgres
Namespace : App\Module\Commercial\Infrastructure\Doctrine\Migrations (modulaire, post-init). Fichier : Version20260601000000.php (à dater par le dev).
-- =====================================================================
-- Référentiels comptables (4 tables statiques seedées par le M1)
-- =====================================================================
CREATE TABLE tva_mode (
id SERIAL PRIMARY KEY,
code VARCHAR(30) NOT NULL UNIQUE,
label VARCHAR(120) NOT NULL,
position INT NOT NULL DEFAULT 0
);
CREATE TABLE payment_delay (
id SERIAL PRIMARY KEY,
code VARCHAR(30) NOT NULL UNIQUE,
label VARCHAR(120) NOT NULL,
position INT NOT NULL DEFAULT 0
);
CREATE TABLE payment_type (
id SERIAL PRIMARY KEY,
code VARCHAR(30) NOT NULL UNIQUE,
label VARCHAR(120) NOT NULL,
position INT NOT NULL DEFAULT 0
);
CREATE TABLE bank (
id SERIAL PRIMARY KEY,
code VARCHAR(30) NOT NULL UNIQUE,
label VARCHAR(120) NOT NULL,
position INT NOT NULL DEFAULT 0
);
-- Seed M1 — référentiels comptables (cf. décision Tristan 28/05).
INSERT INTO tva_mode (code, label, position) VALUES
('FRANCE_VENTES', 'France (ventes)', 10),
('EXPORT_VENTES', 'Export (ventes)', 20),
('INTRACOM_VENTES', 'Intracom (ventes)', 30);
INSERT INTO payment_delay (code, label, position) VALUES
('J15', '15 jours', 10),
('J30', '30 jours', 20),
('A_RECEPTION', 'À réception', 30);
INSERT INTO payment_type (code, label, position) VALUES
('VIREMENT', 'Virement', 10),
('LCR', 'LCR', 20),
('NON_SOUMISE', 'Non soumise', 30),
('CHEQUE', 'Chèque', 40);
INSERT INTO bank (code, label, position) VALUES
('SG', 'Société Générale', 10),
('CIC', 'CIC', 20),
('CA', 'Crédit Agricole', 30);
-- =====================================================================
-- Table principale `client`
-- =====================================================================
CREATE TABLE client (
id SERIAL PRIMARY KEY,
-- Formulaire principal
company_name VARCHAR(180) NOT NULL,
first_name VARCHAR(120),
last_name VARCHAR(120),
phone_primary VARCHAR(20) NOT NULL,
phone_secondary VARCHAR(20),
email VARCHAR(180) NOT NULL,
distributor_id INT REFERENCES client(id) ON DELETE SET NULL,
broker_id INT REFERENCES client(id) ON DELETE SET NULL,
triage_service BOOLEAN NOT NULL DEFAULT FALSE,
-- Onglet Information (Commerciale obligatoire — RG-1.04 — null sinon)
description TEXT,
competitors VARCHAR(255),
founded_at DATE,
employees_count INT,
revenue_amount NUMERIC(15,2),
director_name VARCHAR(120),
profit_amount NUMERIC(15,2),
-- Onglet Comptabilité — Admin seul
siren VARCHAR(20),
account_number VARCHAR(40),
tva_mode_id INT REFERENCES tva_mode(id) ON DELETE RESTRICT,
n_tva VARCHAR(40),
payment_delay_id INT REFERENCES payment_delay(id) ON DELETE RESTRICT,
payment_type_id INT REFERENCES payment_type(id) ON DELETE RESTRICT,
bank_id INT REFERENCES bank(id) ON DELETE RESTRICT,
-- Archive (mécanisme exposé M1)
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
archived_at TIMESTAMPTZ,
-- Soft delete (préparé, non exposé au M1)
deleted_at TIMESTAMPTZ,
-- Timestampable + Blamable (cf. Shared)
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL,
-- Une seule des deux FK distributor/broker doit être non-null (RG-1.03)
CONSTRAINT chk_client_distrib_or_broker
CHECK (NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL))
);
CREATE INDEX idx_client_is_archived ON client(is_archived);
CREATE INDEX idx_client_deleted_at ON client(deleted_at);
CREATE INDEX idx_client_distributor_id ON client(distributor_id);
CREATE INDEX idx_client_broker_id ON client(broker_id);
CREATE INDEX idx_client_created_by ON client(created_by);
CREATE INDEX idx_client_updated_by ON client(updated_by);
-- Unicités métier (partielles : on ignore archives + soft-delete)
CREATE UNIQUE INDEX uq_client_company_name_active
ON client (LOWER(company_name))
WHERE is_archived = FALSE AND deleted_at IS NULL;
CREATE UNIQUE INDEX uq_client_siren_active
ON client (siren)
WHERE siren IS NOT NULL AND is_archived = FALSE AND deleted_at IS NULL;
CREATE UNIQUE INDEX uq_client_email_active
ON client (LOWER(email))
WHERE is_archived = FALSE AND deleted_at IS NULL;
-- =====================================================================
-- Jointure M2M client ↔ category
-- =====================================================================
CREATE TABLE client_category (
client_id INT NOT NULL REFERENCES client(id) ON DELETE CASCADE,
category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT,
PRIMARY KEY (client_id, category_id)
);
CREATE INDEX idx_client_category_category ON client_category(category_id);
-- =====================================================================
-- Sous-collection : Contacts du client (1:n)
-- =====================================================================
CREATE TABLE client_contact (
id SERIAL PRIMARY KEY,
client_id INT NOT NULL REFERENCES client(id) ON DELETE CASCADE,
first_name VARCHAR(120),
last_name VARCHAR(120),
job_title VARCHAR(120),
phone_primary VARCHAR(20),
phone_secondary VARCHAR(20),
email VARCHAR(180),
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL,
-- RG-1.05 : au moins Nom OU Prénom
CONSTRAINT chk_client_contact_name
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)
);
CREATE INDEX idx_client_contact_client ON client_contact(client_id);
-- =====================================================================
-- Sous-collection : Adresses du client (1:n)
-- =====================================================================
CREATE TABLE client_address (
id SERIAL PRIMARY KEY,
client_id INT NOT NULL REFERENCES client(id) ON DELETE CASCADE,
is_prospect BOOLEAN NOT NULL DEFAULT FALSE,
is_delivery BOOLEAN NOT NULL DEFAULT FALSE,
is_billing BOOLEAN NOT NULL DEFAULT FALSE,
country VARCHAR(80) NOT NULL DEFAULT 'France',
postal_code VARCHAR(20) NOT NULL,
city VARCHAR(120) NOT NULL,
street VARCHAR(255) NOT NULL,
street_complement VARCHAR(255),
billing_email VARCHAR(180),
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL,
-- RG-1.06/07/08 : exclusivité prospect vs (livraison ou facturation)
CONSTRAINT chk_client_address_prospect_exclusive
CHECK (NOT (is_prospect = TRUE AND (is_delivery = TRUE OR is_billing = TRUE))),
-- RG-1.11 : billing_email obligatoire ssi is_billing = TRUE
CONSTRAINT chk_client_address_billing_email
CHECK ((is_billing = FALSE AND billing_email IS NULL)
OR (is_billing = TRUE AND billing_email IS NOT NULL))
);
CREATE INDEX idx_client_address_client ON client_address(client_id);
-- M2M client_address ↔ site (RG-1.10 : ≥ 1 site obligatoire)
CREATE TABLE client_address_site (
client_address_id INT NOT NULL REFERENCES client_address(id) ON DELETE CASCADE,
site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
PRIMARY KEY (client_address_id, site_id)
);
-- M2M client_address ↔ client_contact
CREATE TABLE client_address_contact (
client_address_id INT NOT NULL REFERENCES client_address(id) ON DELETE CASCADE,
client_contact_id INT NOT NULL REFERENCES client_contact(id) ON DELETE CASCADE,
PRIMARY KEY (client_address_id, client_contact_id)
);
-- M2M client_address ↔ category (catégorie d'adresse)
CREATE TABLE client_address_category (
client_address_id INT NOT NULL REFERENCES client_address(id) ON DELETE CASCADE,
category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT,
PRIMARY KEY (client_address_id, category_id)
);
-- =====================================================================
-- Sous-collection : RIB du client (1:n)
-- =====================================================================
CREATE TABLE client_rib (
id SERIAL PRIMARY KEY,
client_id INT NOT NULL REFERENCES client(id) ON DELETE CASCADE,
label VARCHAR(120) NOT NULL,
bic VARCHAR(20) NOT NULL,
iban VARCHAR(34) NOT NULL,
position INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL
);
CREATE INDEX idx_client_rib_client ON client_rib(client_id);
3.3 Seed CategoryType (extension du M0)
Au M0, la table category_type a été créée mais reste vide (HP-1 du M0). Le M1 lève cette restriction avec un seed initial des types métier dont le module Tiers a besoin :
INSERT INTO category_type (code, label, position) VALUES
('DISTRIBUTEUR', 'Distributeur', 10),
('COURTIER', 'Courtier', 20),
('SECTEUR', 'Secteur', 30),
('AUTRE', 'Autre', 99);
Note
: le CRUD admin de
CategoryTypereste HP (cf. M0). Le seed est fait via migration ou fixture déclenchée à chaquemake db-reset.
3.4 Entité Client — squelette
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Provider\ClientProvider;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['client:read', 'default:read']],
provider: ClientProvider::class,
),
new Get(
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['client:read', 'client:read:accounting', 'default:read']],
// L'objet retourne contient TOUS les groupes ; le filtre des
// champs comptables se fait au niveau Provider en fonction de
// is_granted('commercial.clients.accounting.view').
provider: ClientProvider::class,
),
new Post(
security: "is_granted('commercial.clients.manage')",
normalizationContext: ['groups' => ['client:read', 'default:read']],
denormalizationContext: ['groups' => ['client:write:main']],
processor: ClientProcessor::class,
),
new Patch(
security: "is_granted('commercial.clients.manage')",
// Le ClientProcessor inspecte les groupes effectivement envoyes
// pour autoriser/refuser onglet par onglet (cf. § 2.8 + § 5).
// Patch sur les champs comptables exige is_granted(accounting.manage).
normalizationContext: ['groups' => ['client:read', 'default:read']],
denormalizationContext: ['groups' => [
'client:write:main',
'client:write:information',
'client:write:accounting',
]],
provider: ClientProvider::class,
processor: ClientProcessor::class,
),
// Pas de Delete au M1 (HP M2). L'archivage est fait via PATCH
// { isArchived: true } gere par le ClientProcessor avec security
// additionnelle is_granted('commercial.clients.archive').
],
)]
#[ORM\Entity(repositoryClass: DoctrineClientRepository::class)]
#[ORM\Table(name: 'client')]
#[Auditable]
class Client implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['client:read'])]
private ?int $id = null;
#[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 180, normalizer: 'trim')]
#[Groups(['client:read', 'client:write:main'])]
private ?string $companyName = null;
// RG-1.01 — first_name OU last_name obligatoire (validation Assert\Callback
// au niveau de l'entite, levee dans le Processor).
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client:read', 'client:write:main'])]
private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client:read', 'client:write:main'])]
private ?string $lastName = null;
#[ORM\Column(length: 20)]
#[Assert\NotBlank]
#[Groups(['client:read', 'client:write:main'])]
private ?string $phonePrimary = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['client:read', 'client:write:main'])]
private ?string $phoneSecondary = null;
#[ORM\Column(length: 180)]
#[Assert\NotBlank]
#[Assert\Email]
#[Groups(['client:read', 'client:write:main'])]
private ?string $email = null;
// RG-1.03 — distributor / broker auto-references (mutuellement exclusives,
// contrainte CHECK en base).
#[ORM\ManyToOne(targetEntity: Client::class)]
#[ORM\JoinColumn(name: 'distributor_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['client:read', 'client:write:main'])]
private ?Client $distributor = null;
#[ORM\ManyToOne(targetEntity: Client::class)]
#[ORM\JoinColumn(name: 'broker_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['client:read', 'client:write:main'])]
private ?Client $broker = null;
#[ORM\Column(name: 'triage_service', options: ['default' => false])]
#[Groups(['client:read', 'client:write:main'])]
private bool $triageService = false;
/** @var Collection<int, Category> */
#[ORM\ManyToMany(targetEntity: Category::class)]
#[ORM\JoinTable(name: 'client_category')]
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
#[Groups(['client:read', 'client:write:main'])]
private Collection $categories;
// === Onglet Information ===
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['client:read', 'client:write:information'])]
private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['client:read', 'client:write:information'])]
private ?string $competitors = null;
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['client:read', 'client:write:information'])]
private ?DateTimeImmutable $foundedAt = null;
#[ORM\Column(nullable: true)]
#[Assert\PositiveOrZero]
#[Groups(['client:read', 'client:write:information'])]
private ?int $employeesCount = null;
#[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
#[Groups(['client:read', 'client:write:information'])]
private ?string $revenueAmount = null;
#[ORM\Column(length: 120, nullable: true)]
#[Groups(['client:read', 'client:write:information'])]
private ?string $directorName = null;
#[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
#[Groups(['client:read', 'client:write:information'])]
private ?string $profitAmount = null;
// === Onglet Comptabilité ===
// Lecture conditionnee via group 'client:read:accounting' (le Provider
// l'ajoute dynamiquement si l'user a la permission accounting.view).
// Ecriture conditionnee via group 'client:write:accounting' (le Processor
// exige is_granted('commercial.clients.accounting.manage') si ce groupe
// est present dans le payload).
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?string $siren = null;
#[ORM\Column(length: 40, nullable: true)]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?string $accountNumber = null;
#[ORM\ManyToOne(targetEntity: TvaMode::class)]
#[ORM\JoinColumn(name: 'tva_mode_id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?TvaMode $tvaMode = null;
#[ORM\Column(length: 40, nullable: true)]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?string $nTva = null;
#[ORM\ManyToOne(targetEntity: PaymentDelay::class)]
#[ORM\JoinColumn(name: 'payment_delay_id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?PaymentDelay $paymentDelay = null;
#[ORM\ManyToOne(targetEntity: PaymentType::class)]
#[ORM\JoinColumn(name: 'payment_type_id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?PaymentType $paymentType = null;
#[ORM\ManyToOne(targetEntity: Bank::class)]
#[ORM\JoinColumn(name: 'bank_id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?Bank $bank = null;
// === Sous-collections (exposées via sous-ressources API Platform dédiées) ===
/** @var Collection<int, ClientContact> */
#[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contacts;
/** @var Collection<int, ClientAddress> */
#[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $addresses;
/** @var Collection<int, ClientRib> */
#[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $ribs;
// === Archive / Soft delete ===
#[ORM\Column(name: 'is_archived', options: ['default' => false])]
#[Groups(['client:read', 'client:write:archive'])]
private bool $isArchived = false;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['client:read'])]
private ?DateTimeImmutable $archivedAt = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $deletedAt = null;
public function __construct()
{
$this->categories = new ArrayCollection();
$this->contacts = new ArrayCollection();
$this->addresses = new ArrayCollection();
$this->ribs = new ArrayCollection();
}
// Getters / setters omis pour la lisibilite — pattern Starseed standard.
}
3.5 Squelettes des autres entités
ClientContact, ClientAddress, ClientRib : même pattern (#[Auditable], TimestampableBlamableTrait, FK client_id). ClientRib.iban et ClientRib.bic portent #[AuditIgnore] (champs sensibles).
Référentiels (TvaMode, PaymentDelay, PaymentType, Bank) : entités lecture seule via API Platform GetCollection + Get uniquement (security commercial.clients.view). Pas de POST/PATCH/DELETE au M1 (HP). Pas de Timestampable+Blamable (whitelistés dans EntitiesAreTimestampableBlamableTest::EXCLUDED).
4. API REST (API Platform)
4.1 GET /api/clients — Liste
- Security :
is_granted('commercial.clients.view') - Query params :
includeArchived=true|false(defaultfalse)categoryType=<code>(filtre par type de catégorie viaSearchFilter)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:readUNIQUEMENT (pas les champsclient:read:accountingsauf si l'user a la permissionaccounting.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:accountingsont inclus uniquement si l'user a la permissioncommercial.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:mainuniquement) :
{
"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/403409 Conflictsi 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→ exigeis_granted('commercial.clients.accounting.manage') - Si le payload contient
isArchived→ exigeis_granted('commercial.clients.archive')
- Si le payload contient un champ du groupe
- 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 :
POSTdu 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 leClientContactProcessor. → 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
ClientExportControlleravec#[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, securityis_granted('commercial.clients.view')GET /api/payment_delays— idemGET /api/payment_types— idemGET /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)
config/sidebar.php— sectioncommercial, item « Répertoire clients » :
[
'label' => 'sidebar.commercial.clients',
'to' => '/commercial/clients',
'icon' => 'mdi:account-group-outline',
'module' => 'commercial',
'permission' => 'commercial.clients.view',
],
-
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
- Admin :
-
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]suribanetbic(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) oulastName(Nom du contact principal) doit être renseigné. Sinon → 422. - RG-1.02 : Le champ
phoneSecondaryest 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
distributoretbrokersont 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 dedistributor= clients ayant au moins une catégorie de typeDISTRIBUTEUR; idem pourbrokeravecCOURTIER.
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 groupeclient:write:information. Pour les autres rôles, ces champs restent optionnels. Implémenté via un validator customClientInformationCompletenessValidatorinvoqué par leClientProcessorquand le user porte le rôle Commerciale.
Onglet Contact
- RG-1.05 : Un bloc Contact est valide dès lors que au moins
firstNameOUlastNameest 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/clientsrequiert 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 collectionclient.contactspeut 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 = TRUEsur une adresse, les champs/casesisDeliveryetisBillingsont 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 caseisProspectest 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 caseisProspectest masquée côté UI et forcée à FALSE serveur (couvert par la même CHECK). - RG-1.09 : Le champ
cityest prérempli automatiquement à partir dupostalCodevia 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 :postalCodematche^[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)surclientAddress.sites. - RG-1.11 : Le champ
billingEmailest visible et obligatoire uniquement siisBilling = 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
bankest visible et obligatoire uniquement sipaymentType.code = 'VIREMENT'. Validation server-side dans leClientProcessor: sipayment_type.code = VIREMENTetbank IS NULL→ 422. - RG-1.13 : Les champs RIB (
label,bic,iban) sont obligatoires si au moins un bloc RIB est présent ETpaymentType.code = 'LCR'. C'est-à-dire :- Si
paymentType.code = LCRETclient.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).
- Si
Onglet Contact (renforcement)
- RG-1.14 : (cf. ci-dessus) — au moins 1 bloc Contact valide pour finaliser l'onglet.
Unicité
- RG-1.15 : Le
sirenest unique parmi les clients non archivés ET non soft-deletés (index partieluq_client_siren_active). Tentative de doublon → 409 avec message"Un client avec le SIREN \"{siren}\" existe déjà.". - RG-1.16 : Le
companyNameest unique (case-insensitive) parmi les clients non archivés ET non soft-deletés (index partieluq_client_company_name_active). Doublon → 409. - RG-1.17 : L'
emailprincipal est unique (case-insensitive) parmi les clients non archivés ET non soft-deletés (index partieluq_client_email_active). Doublon → 409.
Normalisation serveur (formatage)
- RG-1.18 :
companyNameest 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(surClientetClientContact) 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,phoneSecondarysurClient, et idem surClientContact) sont normalisés à chiffres uniquement côté serveur (preg_replace('/\D+/', '', $v)). Stockage :0612345678. Le format affichageXX XX XX XX XXest 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 permissioncommercial.clients.archive. PoseisArchived = trueETarchivedAt = 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. PoseisArchived = falseETarchivedAt = 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/clientsexclut par défaut les clients archivés (is_archived = TRUE) ET soft-deletés (deleted_at IS NOT NULL). Filtré dans leClientProvider. - 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+createdByposé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',
changescorrect ; 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(): appeluseApi().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_atexiste 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
.docxinvalidé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\IbanetAssert\Biccô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é
Partnerou similaire. Au M1, on garde l'auto-référence simple.
10. Liens & dépendances
Liens
- Spec front :
./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 - Doc audit-log :
../../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— M2Mclient_category(à étendre par seed M1 surCategoryType) - Module
Sites:Site(3 sites seedés 86/17/82) — M2Mclient_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 jumellesSupplier/SupplierContact/SupplierAddress/SupplierRib+ référentiels comptables partagés). - M-Prestas : idem.
- M-Commandes : FK
client_idsur 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 :
- Migration BDD M1 (tables client + sous-collections + référentiels comptables + index partiels + seed CategoryType + seed refs comptables)
- Entités + Repositories (Client, ClientContact, ClientAddress, ClientRib, TvaMode, PaymentDelay, PaymentType, Bank)
- Provider + Processor (ClientProvider, ClientProcessor — incl. normalisation, archivage, conditional accounting)
- Référentiels lecture seule (4 endpoints GetCollection + Get)
- Sous-ressources (ClientContactProcessor, ClientAddressProcessor, ClientRibProcessor)
- Export XLSX (controller custom)
- RBAC :
CommercialModule::permissions()+ sync 3 sources + tests PHPUnit personas- Tests PHPUnit : matrice RG-1.01 → RG-1.27 (8.1)
- Front : page Répertoire (
/commercial/clients) + composableuseClientsRepository()- Front : page Création (
/commercial/clients/new) + composablesuseClientForm(),useAddressAutocomplete()- Front : page Consultation (
/commercial/clients/{id})- Front : page Modification (
/commercial/clients/{id}/edit)- Front : filter
formatPhoneFR+ tests Vitest (cf. 8.2)- i18n + Sidebar (clé
sidebar.commercial.clients, traductions)
Actions manuelles à faire dans Lesstime (Matthieu)
- Créer le TaskGroup
M1 — Répertoire clientsdans le projetERP / Starseed. - Créer les ~14 tickets ci-dessus avec dépendances séquentielles (back avant front, migration en tête).
- Mettre à jour le frontmatter de ce fichier (
lesstime_taskgroup_id) avec l'id réel.