fix : retours métier ERP-193 (4 répertoires) (#139)
Auto Tag Develop / tag (push) Successful in 11s
Auto Tag Develop / tag (push) Successful in 11s
Lot de retours métier **ERP-193** (« Fix tous les retours starseed »), transverse aux 4 répertoires (clients, fournisseurs, prestataires, transporteurs).
## Contenu
- **Pagination** : défaut à 25 items/page sur les 4 répertoires.
- **Libellé** : colonne « Dernière activité » → « Dernière modification ».
- **Consultation** : masquage des onglets vides (coquilles « à venir » + onglets de données sans donnée).
- **Chiffre d'affaires** : plafonné à 999 999 999 999,99 (clamp front + `Assert\LessThanOrEqual` back).
- **Date de création** : interdiction des dates futures (`:max` MalioDate + `Assert\LessThanOrEqual('today')` back).
- **Caractères spéciaux** : blocage des caractères parasites (`²³§~#|…`) dans les champs texte via une allow-list par profil (nom de personne / texte libre / adresse / code alphanumérique) — filtrage front à la frappe + `Assert\Regex` back autoritaire. Email/IBAN/BIC/TVA conservent leurs validateurs de format.
- **UI** : champs en consultation et onglets validés grisés (`readonly` → `disabled`).
- **UI** : boutons « Archiver » en rouge (variant `danger`).
## Tests
- Back : nouveaux tests RG (plafond CA, dates futures, caractères spéciaux) + garde-fou contraintes — suite complète verte (813 tests).
- Front : nouveaux tests unitaires (sanitizers, helpers date/montant) — 615 tests verts, eslint clean.
---------
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #139
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #139.
This commit is contained in:
@@ -19,6 +19,7 @@ use App\Shared\Domain\Contract\ClientInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -162,6 +163,7 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
|
||||
#[ORM\Column(length: 180)]
|
||||
#[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom de l\'entreprise doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom de l\'entreprise ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $companyName = null;
|
||||
|
||||
@@ -214,11 +216,14 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?string $competitors = null;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
// Date de creation jamais dans le futur (retour metier ERP-193) : <= aujourd'hui.
|
||||
#[Assert\LessThanOrEqual(value: 'today', message: 'La date de création ne peut pas être dans le futur.')]
|
||||
// Format d'ENTREE strict ISO `Y-m-d` (le `!` remet l'heure a 00:00:00). Sans
|
||||
// ce format, PHP DateTime accepte des formes ambigues : « 12/25/2026 » (que
|
||||
// le front MalioDate juge invalide en JJ/MM/AAAA) serait sinon interprete en
|
||||
@@ -233,12 +238,16 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?int $employeesCount = null;
|
||||
|
||||
// Chiffre d'affaires plafonne a 999 999 999 999,99 (12 chiffres, retour metier
|
||||
// ERP-193). La colonne (decimal 15,2) tolere plus, mais le metier borne la saisie.
|
||||
#[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
|
||||
#[Assert\LessThanOrEqual(value: 999_999_999_999.99, message: 'Le chiffre d\'affaires ne peut pas dépasser 999 999 999 999,99.')]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?string $revenueAmount = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?string $directorName = null;
|
||||
|
||||
@@ -257,6 +266,7 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
|
||||
|
||||
#[ORM\Column(length: 40, nullable: true)]
|
||||
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?string $accountNumber = null;
|
||||
|
||||
@@ -267,6 +277,7 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
|
||||
|
||||
#[ORM\Column(length: 40, nullable: true)]
|
||||
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?string $nTva = null;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Shared\Domain\Contract\ClientAddressInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -158,17 +159,20 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $streetComplement = null;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -94,16 +95,19 @@ class ClientContact implements TimestampableInterface, BlamableInterface
|
||||
// deux restent nullable au niveau ORM.
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private ?string $jobTitle = null;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SupplierInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -171,6 +172,7 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
|
||||
#[ORM\Column(length: 180)]
|
||||
#[Assert\NotBlank(message: 'Le nom du fournisseur est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du fournisseur doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du fournisseur ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['supplier:read', 'supplier:write:main'])]
|
||||
private ?string $companyName = null;
|
||||
|
||||
@@ -195,11 +197,14 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['supplier:read', 'supplier:write:information'])]
|
||||
private ?string $competitors = null;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
#[Groups(['supplier:read', 'supplier:write:information'])]
|
||||
// Date de creation jamais dans le futur (retour metier ERP-193) : <= aujourd'hui.
|
||||
#[Assert\LessThanOrEqual(value: 'today', message: 'La date de création ne peut pas être dans le futur.')]
|
||||
// Format d'ENTREE strict ISO `Y-m-d` : sans lui, PHP DateTime accepte des
|
||||
// formes ambigues (« 12/25/2026 », jugee invalide par MalioDate en JJ/MM/AAAA,
|
||||
// serait lue en M/J -> 25 decembre et acceptee a tort). Avec le format, toute
|
||||
@@ -212,12 +217,16 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
|
||||
#[Groups(['supplier:read', 'supplier:write:information'])]
|
||||
private ?int $employeesCount = null;
|
||||
|
||||
// Chiffre d'affaires plafonne a 999 999 999 999,99 (12 chiffres, retour metier
|
||||
// ERP-193). La colonne (decimal 15,2) tolere plus, mais le metier borne la saisie.
|
||||
#[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
|
||||
#[Assert\LessThanOrEqual(value: 999_999_999_999.99, message: 'Le chiffre d\'affaires ne peut pas dépasser 999 999 999 999,99.')]
|
||||
#[Groups(['supplier:read', 'supplier:write:information'])]
|
||||
private ?string $revenueAmount = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['supplier:read', 'supplier:write:information'])]
|
||||
private ?string $directorName = null;
|
||||
|
||||
@@ -243,6 +252,7 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
|
||||
|
||||
#[ORM\Column(length: 40, nullable: true)]
|
||||
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
|
||||
#[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
|
||||
private ?string $accountNumber = null;
|
||||
|
||||
@@ -253,6 +263,7 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
|
||||
|
||||
#[ORM\Column(length: 40, nullable: true)]
|
||||
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
|
||||
#[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
|
||||
private ?string $nTva = null;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SupplierAddressInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -154,17 +155,20 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private ?string $streetComplement = null;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -99,16 +100,19 @@ class SupplierContact implements TimestampableInterface, BlamableInterface
|
||||
// deux restent nullable au niveau ORM.
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:contacts'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:contacts'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:contacts'])]
|
||||
private ?string $jobTitle = null;
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -156,6 +157,7 @@ class Provider implements TimestampableInterface, BlamableInterface
|
||||
#[ORM\Column(length: 180)]
|
||||
#[Assert\NotBlank(message: 'Le nom du prestataire est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du prestataire doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du prestataire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['provider:read', 'provider:write:main'])]
|
||||
private ?string $companyName = null;
|
||||
|
||||
@@ -200,6 +202,7 @@ class Provider implements TimestampableInterface, BlamableInterface
|
||||
|
||||
#[ORM\Column(length: 40, nullable: true)]
|
||||
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
|
||||
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
|
||||
private ?string $accountNumber = null;
|
||||
|
||||
@@ -210,6 +213,7 @@ class Provider implements TimestampableInterface, BlamableInterface
|
||||
|
||||
#[ORM\Column(length: 40, nullable: true)]
|
||||
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
|
||||
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
|
||||
private ?string $nTva = null;
|
||||
|
||||
@@ -274,8 +278,7 @@ class Provider implements TimestampableInterface, BlamableInterface
|
||||
/**
|
||||
* RG-3.09 : toute categorie posee sur le prestataire doit etre de type
|
||||
* PRESTATAIRE -> sinon 422 avec violation sur le champ `categories`
|
||||
* (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
|
||||
* ProviderAddress::validateCategoryType. S'appuie sur
|
||||
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
|
||||
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
|
||||
* acceptee des qu'elle PORTE le type PRESTATAIRE ; pas d'import du module
|
||||
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API
|
||||
|
||||
@@ -14,30 +14,26 @@ use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderAddr
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
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;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* Adresse d'un prestataire (1:n) — onglet Adresse. Version SIMPLIFIEE de
|
||||
* SupplierAddress : PAS de address_type (PROSPECT/DEPART/RENDU), PAS de bennes,
|
||||
* PAS de triage_provider (champs specifiques fournisseur). Champs : country /
|
||||
* postal_code / city / street / street_complement + M2M sites / contacts /
|
||||
* categories.
|
||||
* postal_code / city / street / street_complement + M2M sites / contacts.
|
||||
*
|
||||
* Relations M2M :
|
||||
* - sites : SiteInterface (module Sites) via resolve_target_entities — au moins
|
||||
* un site obligatoire (RG-3.05, Assert\Count). Site n'a pas de `code`.
|
||||
* - contacts : ProviderContact (meme module).
|
||||
* - categories : CategoryInterface (module Catalog) via resolve_target_entities —
|
||||
* type PRESTATAIRE attendu (RG-3.09, Assert\Callback validateCategoryType).
|
||||
*
|
||||
* Embarquee sous `provider.addresses` au detail (groupe provider:item:read,
|
||||
* maillon (a)).
|
||||
@@ -49,7 +45,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
* - GET /api/provider_addresses/{id} : lecture unitaire (security view) — la lecture
|
||||
* courante reste via le parent. Pas de GET collection autonome.
|
||||
* Tout passe par le ProviderAddressProcessor (rattachement parent + cloisonnement
|
||||
* d'ecriture des sites, § 2.13). Les regles RG-3.05/3.06/3.09 sont portees par les
|
||||
* d'ecriture des sites, § 2.13). Les regles RG-3.05/3.06 sont portees par les
|
||||
* contraintes de l'entite (jouees avant le processor).
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable / Blamable.
|
||||
@@ -58,9 +54,9 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('technique.providers.view')",
|
||||
// site:read + category:read : embarquent les Site / Category lies
|
||||
// (maillon (c)) plutot que des IRI nus dans le retour.
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
||||
// site:read : embarque les Site lies (maillon (c)) plutot que des IRI
|
||||
// nus dans le retour.
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'default:read']],
|
||||
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
),
|
||||
@@ -75,13 +71,13 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
// manuellement par ProviderAddressProcessor::linkParent (404 si absent).
|
||||
read: false,
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['provider:write:addresses']],
|
||||
processor: ProviderAddressProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['provider:write:addresses']],
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
processor: ProviderAddressProcessor::class,
|
||||
@@ -101,13 +97,6 @@ class ProviderAddress implements TimestampableInterface, BlamableInterface, Prov
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
/**
|
||||
* RG-3.09 : seules les categories PORTANT ce type sont autorisees sur une
|
||||
* adresse prestataire. S'appuie sur CategoryInterface::getCategoryTypeCodes()
|
||||
* (pas d'import du module Catalog — regle ABSOLUE n°1).
|
||||
*/
|
||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
@@ -135,17 +124,20 @@ class ProviderAddress implements TimestampableInterface, BlamableInterface, Prov
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private ?string $streetComplement = null;
|
||||
|
||||
@@ -171,46 +163,10 @@ class ProviderAddress implements TimestampableInterface, BlamableInterface, Prov
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private Collection $contacts;
|
||||
|
||||
// RG-3.09 : au moins une categorie de type PRESTATAIRE par adresse (le type est
|
||||
// controle par validateCategoryType ; le minimum par Assert\Count, miroir sites).
|
||||
/** @var Collection<int, CategoryInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
||||
#[ORM\JoinTable(name: 'provider_address_category')]
|
||||
#[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private Collection $categories;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->sites = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
$this->categories = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.09 : toute categorie posee sur une adresse prestataire doit etre de
|
||||
* type PRESTATAIRE -> sinon 422 avec violation sur le champ `categories`
|
||||
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
|
||||
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
|
||||
* acceptee des qu'elle PORTE le type PRESTATAIRE ; pas d'import du module
|
||||
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateCategoryType(ExecutionContextInterface $context): void
|
||||
{
|
||||
foreach ($this->categories as $category) {
|
||||
if ($category instanceof CategoryInterface
|
||||
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
|
||||
$context->buildViolation('Type de catégorie non autorisé (PRESTATAIRE attendu).')
|
||||
->atPath('categories')
|
||||
->addViolation()
|
||||
;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
$this->sites = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -345,26 +301,4 @@ class ProviderAddress implements TimestampableInterface, BlamableInterface, Prov
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CategoryInterface> */
|
||||
public function getCategories(): Collection
|
||||
{
|
||||
return $this->categories;
|
||||
}
|
||||
|
||||
public function addCategory(CategoryInterface $category): static
|
||||
{
|
||||
if (!$this->categories->contains($category)) {
|
||||
$this->categories->add($category);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCategory(CategoryInterface $category): static
|
||||
{
|
||||
$this->categories->removeElement($category);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -102,16 +103,19 @@ class ProviderContact implements TimestampableInterface, BlamableInterface, Prov
|
||||
// champs restent nullable au niveau ORM.
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['provider:item:read', 'provider:write:contacts'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['provider:item:read', 'provider:write:contacts'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['provider:item:read', 'provider:write:contacts'])]
|
||||
private ?string $jobTitle = null;
|
||||
|
||||
|
||||
+1
-2
@@ -33,8 +33,7 @@ use Symfony\Component\Validator\ConstraintViolationList;
|
||||
* sites de l'adresse (RG-3.05 / § 2.13). Les regles de l'onglet Adresse sont
|
||||
* garanties en amont par des contraintes sur l'entite, jouees par API Platform
|
||||
* avant ce processor : RG-3.06 (code postal, Assert\Regex), RG-3.05 (>= 1 site,
|
||||
* Assert\Count), RG-3.09 (categorie de type PRESTATAIRE, Assert\Callback
|
||||
* ProviderAddress::validateCategoryType).
|
||||
* Assert\Count).
|
||||
* - DELETE : aucune regle metier specifique (suppression physique directe).
|
||||
*
|
||||
* La security de l'operation (technique.providers.manage) est appliquee par API
|
||||
|
||||
@@ -63,7 +63,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
/**
|
||||
* Type de categorie exige pour un prestataire et ses adresses (RG-3.09).
|
||||
* Type de categorie exige pour un prestataire (RG-3.09).
|
||||
* Miroir de Provider::REQUIRED_CATEGORY_TYPE_CODE (non importable — regle n°1).
|
||||
*/
|
||||
private const string PROVIDER_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
|
||||
@@ -117,7 +117,7 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
$maintenance->setPaymentType($this->paymentType($manager, 'VIREMENT'));
|
||||
$maintenance->setBank($this->bank($manager, 'SG'));
|
||||
$this->addContact($maintenance, 'Marie', 'Martin', 'Responsable', '05 49 00 00 01', null, 'marie.martin@maintenance-pro.fr');
|
||||
$this->addAddress($maintenance, ['Chatellerault', 'Saint-Jean'], '86000', 'Poitiers', '12 rue des Acacias', categoryNames: ['Maintenance industrielle']);
|
||||
$this->addAddress($maintenance, ['Chatellerault', 'Saint-Jean'], '86000', 'Poitiers', '12 rue des Acacias');
|
||||
$this->addRib($maintenance, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
$transport->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
|
||||
$transport->setPaymentType($this->paymentType($manager, 'CHEQUE'));
|
||||
$this->addContact($transport, 'Thomas', 'Petit', 'Responsable logistique', '05 56 31 32 33', null, 'thomas.petit@transport-express.fr');
|
||||
$this->addAddress($transport, ['Saint-Jean'], '17400', 'Fontenet', '4 zone des Transporteurs', categoryNames: ['Transport']);
|
||||
$this->addAddress($transport, ['Saint-Jean'], '17400', 'Fontenet', '4 zone des Transporteurs');
|
||||
}
|
||||
|
||||
// === Prestataire minimal — contact par le seul nom (RG-3.04), site 86 ===
|
||||
@@ -237,8 +237,7 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
* moins un site est rattache (RG-3.05) ; categories d'adresse de type
|
||||
* PRESTATAIRE (RG-3.09).
|
||||
*
|
||||
* @param list<string> $siteNames au moins un site (RG-3.05)
|
||||
* @param list<string> $categoryNames categories de type PRESTATAIRE (RG-3.09)
|
||||
* @param list<string> $siteNames au moins un site (RG-3.05)
|
||||
*/
|
||||
private function addAddress(
|
||||
Provider $provider,
|
||||
@@ -247,7 +246,6 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
string $city,
|
||||
string $street,
|
||||
?string $streetComplement = null,
|
||||
array $categoryNames = [],
|
||||
int $position = 0,
|
||||
): void {
|
||||
$address = new ProviderAddress();
|
||||
@@ -262,9 +260,6 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
foreach ($siteNames as $siteName) {
|
||||
$address->addSite($this->site($siteName));
|
||||
}
|
||||
foreach ($categoryNames as $categoryName) {
|
||||
$address->addCategory($this->category($this->manager, $categoryName));
|
||||
}
|
||||
|
||||
$provider->addAddress($address);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Entity\UploadedDocument;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -145,6 +146,7 @@ class Carrier implements TimestampableInterface, BlamableInterface
|
||||
maxMessage: 'Le nom du transporteur ne peut dépasser {{ limit }} caractères.',
|
||||
normalizer: 'trim',
|
||||
)]
|
||||
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $name = null;
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ 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 App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -123,16 +124,19 @@ class CarrierAddress implements TimestampableInterface, BlamableInterface
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'L\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(name: 'street_complement', length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
private ?string $streetComplement = null;
|
||||
|
||||
|
||||
@@ -15,14 +15,15 @@ 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 App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Contact d'un transporteur (1:n) — onglet Contact (M4). Jumeau de
|
||||
* SupplierContact (M2) : au moins le prenom OU le nom (RG-4.08, garanti par le
|
||||
* CHECK chk_carrier_contact_name + le Processor), max 2 telephones.
|
||||
* Contact d'un transporteur (1:n) — onglet Contact (M4). ERP-193 (retour metier) :
|
||||
* le bloc Contact est OPTIONNEL — la garde « prenom OU nom » (ex RG-4.08) est
|
||||
* retiree. Reste applicable : max 2 telephones.
|
||||
*
|
||||
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
|
||||
* transporteur). Ecriture : groupe `carrier:write:contacts`.
|
||||
@@ -102,16 +103,19 @@ class CarrierContact implements TimestampableInterface, BlamableInterface
|
||||
// Processor + CHECK BDD). Les colonnes restent nullable au niveau ORM.
|
||||
#[ORM\Column(name: 'first_name', length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(name: 'last_name', length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(name: 'job_title', length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
|
||||
private ?string $jobTitle = null;
|
||||
|
||||
|
||||
+8
-38
@@ -23,23 +23,19 @@ use function is_string;
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Contact d'un transporteur (M4,
|
||||
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
|
||||
* perimetre ERP-160. RG-4.08 (correctif, alignement M1/M2/M3) : un contact exige
|
||||
* au moins le PRENOM OU le NOM (la fonction / le telephone / l'email seuls ne
|
||||
* suffisent pas), porte a la fois par le CHECK BDD chk_carrier_contact_name et par
|
||||
* ce Processor ; le « max 2 telephones » reste une specificite M4.
|
||||
* perimetre ERP-160. ERP-193 (retour metier) : l'onglet Contact n'est plus
|
||||
* obligatoire — la garde « prenom OU nom » (ex RG-4.08) est retiree, un contact
|
||||
* peut donc etre cree sans nom. Le « max 2 telephones » reste une specificite M4.
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au transporteur parent (linkParent),
|
||||
* normalisation serveur RG-4.13 (prenom/nom Title Case, email lowercase),
|
||||
* mapping du tableau d'ecriture `phones` -> phonePrimary/phoneSecondary
|
||||
* (max 2, chiffres uniquement), puis garde « prenom OU nom » avant persistance.
|
||||
* (max 2, chiffres uniquement) avant persistance.
|
||||
* - DELETE : aucune regle metier specifique (suppression physique directe).
|
||||
*
|
||||
* La garde « prenom OU nom » vit ICI (double du CHECK BDD) pour transformer une
|
||||
* violation SQL (500 generique) en 422 propre rattachee au champ `firstName`
|
||||
* (mapping inline ERP-101). Le « max 2 telephones » est rattache au champ `phones` : seul
|
||||
* point de saisie des numeros (les colonnes phonePrimary/phoneSecondary sont en
|
||||
* lecture seule).
|
||||
* Le « max 2 telephones » est rattache au champ `phones` : seul point de saisie
|
||||
* des numeros (les colonnes phonePrimary/phoneSecondary sont en lecture seule).
|
||||
*
|
||||
* La security d'operation (transport.carriers.manage) est appliquee par API
|
||||
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut
|
||||
@@ -77,7 +73,6 @@ final class CarrierContactProcessor implements ProcessorInterface
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->normalize($data);
|
||||
$this->applyPhones($data);
|
||||
$this->validateName($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
@@ -115,9 +110,8 @@ final class CarrierContactProcessor implements ProcessorInterface
|
||||
|
||||
/**
|
||||
* Normalisation serveur RG-4.13 des champs texte. Toutes les methodes du
|
||||
* normalizer sont null-safe : une chaine vide apres trim devient null (donc la
|
||||
* garde RG-4.08 detecte bien « champ non rempli »). Les telephones sont
|
||||
* traites a part (applyPhones).
|
||||
* normalizer sont null-safe : une chaine vide apres trim devient null. Les
|
||||
* telephones sont traites a part (applyPhones).
|
||||
*/
|
||||
private function normalize(CarrierContact $contact): void
|
||||
{
|
||||
@@ -186,30 +180,6 @@ final class CarrierContactProcessor implements ProcessorInterface
|
||||
$contact->setPhones(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.08 (alignement M1/M2/M3) : un bloc Contact exige au moins le PRENOM OU le
|
||||
* NOM — un contact se materialise par son nom ; fonction / telephone / email
|
||||
* seuls ne suffisent pas. Double garde avec le CHECK BDD chk_carrier_contact_name
|
||||
* — leve une 422 propre rattachee a `firstName` plutot qu'une 500 SQL. Joue apres
|
||||
* normalisation + mapping telephones, donc les chaines vides sont deja null.
|
||||
*/
|
||||
private function validateName(CarrierContact $contact): void
|
||||
{
|
||||
if (null === $contact->getFirstName() && null === $contact->getLastName()) {
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Le prénom ou le nom du contact est obligatoire.',
|
||||
null,
|
||||
[],
|
||||
$contact,
|
||||
'firstName',
|
||||
null,
|
||||
));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim + chaine vide -> null (la fonction n'est pas normalisee en casse,
|
||||
* contrairement aux noms de personne). Evite de persister une chaine vide
|
||||
|
||||
@@ -195,7 +195,8 @@ class CarrierFixtures extends Fixture implements DependentFixtureInterface
|
||||
|
||||
/**
|
||||
* Ajoute un contact normalise au transporteur (cascade persist via
|
||||
* Carrier.contacts). Prenom OU nom toujours fourni (RG-4.08, chk_carrier_contact_name).
|
||||
* Carrier.contacts). Le bloc Contact est optionnel (ERP-193) ; les fixtures
|
||||
* fournissent neanmoins un nom pour des donnees de demonstration realistes.
|
||||
*/
|
||||
private function addContact(
|
||||
Carrier $carrier,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Validation;
|
||||
|
||||
/**
|
||||
* Profils de caracteres autorises pour les champs texte libres (retour metier
|
||||
* ERP-193 : bloquer les caracteres parasites « ²³§~#| … » sans casser les saisies
|
||||
* legitimes — accents, apostrophe, tiret, &, etc.).
|
||||
*
|
||||
* Approche allow-list (pas blacklist) : on definit ce qui est AUTORISE par famille
|
||||
* de champ, le reste est rejete. Couche AUTORITAIRE (back) : `#[Assert\Regex]` avec
|
||||
* ces patterns et messages FR ; le front (shared/utils/textSanitize.ts) miroite ces
|
||||
* memes ensembles en filtrant la saisie a la frappe.
|
||||
*
|
||||
* Note : `Assert\Regex` laisse passer null et la chaine vide (champs nullable OK) ;
|
||||
* seules les valeurs non vides sont controlees.
|
||||
*/
|
||||
final class TextInputPattern
|
||||
{
|
||||
/**
|
||||
* Noms de personnes (Nom, Prenom, Dirigeant) : lettres (accents inclus),
|
||||
* espace, apostrophe droite/courbe, tiret, point. Ni chiffres ni symboles.
|
||||
*/
|
||||
public const string PERSON_NAME = '/^[\p{L}\p{M} \'’.\-]+$/u';
|
||||
public const string PERSON_NAME_MESSAGE = 'Ce champ ne peut contenir que des lettres, espaces, apostrophes, tirets et points.';
|
||||
|
||||
/**
|
||||
* Texte societe / libre (Raison sociale, Concurrents, Fonction) : comme un nom
|
||||
* + chiffres, virgule, esperluette, slash, parentheses, degre (n°). Couvre
|
||||
* « Dupont & Fils », « Achats/Ventes », « Pole 2 ».
|
||||
*/
|
||||
// 0-9 (et pas \p{N}) : \p{N} engloberait les exposants ² ³ — justement parasites.
|
||||
public const string FREE_TEXT = '/^[\p{L}\p{M}0-9 \'’.,&\/()°\-]+$/u';
|
||||
public const string FREE_TEXT_MESSAGE = 'Ce champ contient des caractères non autorisés.';
|
||||
|
||||
/**
|
||||
* Adresse (voie, complement, ville) : lettres, chiffres, espace, apostrophe,
|
||||
* point, virgule, slash, degre, tiret. Couvre « 12 bis, rue de l’Église ».
|
||||
*/
|
||||
public const string ADDRESS = '/^[\p{L}\p{M}0-9 \'’.,\/°\-]+$/u';
|
||||
public const string ADDRESS_MESSAGE = 'Cette adresse contient des caractères non autorisés.';
|
||||
|
||||
/**
|
||||
* Codes alphanumeriques majuscules (N° de compte comptable, N° de TVA) :
|
||||
* uniquement A-Z et 0-9. Le front force la majuscule a la frappe.
|
||||
*/
|
||||
public const string CODE_ALNUM = '/^[A-Z0-9]+$/';
|
||||
public const string CODE_ALNUM_MESSAGE = 'Ce champ ne doit contenir que des lettres majuscules et des chiffres.';
|
||||
}
|
||||
@@ -444,12 +444,6 @@ final class ColumnCommentsCatalog
|
||||
'provider_contact_id' => 'FK -> provider_contact.id, ON DELETE CASCADE — contact associe a l adresse.',
|
||||
],
|
||||
|
||||
'provider_address_category' => [
|
||||
'_table' => 'Jointure M2M provider_address <-> category — categories d adresse de type PRESTATAIRE (RG-3.09).',
|
||||
'provider_address_id' => 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.',
|
||||
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type PRESTATAIRE (RG-3.09).',
|
||||
],
|
||||
|
||||
'provider_rib' => [
|
||||
'_table' => 'Coordonnees bancaires d un prestataire (1:n) — >= 1 RIB attendu selon le type de reglement (RG-3.08). Tous les champs audites (pas d AuditIgnore).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
@@ -509,11 +503,11 @@ final class ColumnCommentsCatalog
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'carrier_contact' => [
|
||||
'_table' => 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins le prenom OU le nom rempli (RG-4.08, chk_carrier_contact_name), max 2 telephones.',
|
||||
'_table' => 'Contacts d un transporteur (1:n) — onglet Contact (M4). Bloc optionnel (ERP-193) ; max 2 telephones.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.',
|
||||
'first_name' => 'Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).',
|
||||
'last_name' => 'Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).',
|
||||
'first_name' => 'Prenom du contact (capitalise serveur). Optionnel (ERP-193).',
|
||||
'last_name' => 'Nom du contact (capitalise serveur). Optionnel (ERP-193).',
|
||||
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
|
||||
'phone_primary' => 'Telephone principal — chiffres uniquement (normalisation serveur).',
|
||||
'phone_secondary' => 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).',
|
||||
|
||||
Reference in New Issue
Block a user