From 54d8327fa5bb10210325d1d69f2125adc0e9afcc Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Fri, 12 Jun 2026 14:25:27 +0000 Subject: [PATCH] =?UTF-8?q?feat(technique)=20:=20entit=C3=A9s=20+=20reposi?= =?UTF-8?q?tories=20Provider*=20(ERP-133)=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR **empilée sur ERP-132** (#90) — base = \`feature/ERP-132-migrer-schema-bdd-m3\` (ERP-132 pas encore mergé dans develop). À rebaser sur develop une fois #90 mergée. ## Périmètre (ticket Lesstime #133, M3 § 3.3/3.4/2.12/4.0) Entités Doctrine + mapping ApiResource (squelette) + repository avec hydratation anti-N+1. Miroir des entités `Supplier*` (M2), **amputé de l'onglet Information** et **augmenté de `provider.sites`** (M2M direct, RG-3.03). ### Créé - `Provider`, `ProviderContact`, `ProviderAddress` (simplifiée : pas de `addressType`/`bennes`/`triageProvider`), `ProviderRib` — `#[Auditable]` + Timestampable/Blamable. - `ProviderRepositoryInterface` + `DoctrineProviderRepository` : `createListQueryBuilder` (filtres + tri seuls) + `hydrateListCollections` anti-N+1 (catégories puis **sites en relation directe**, requêtes `IN` bornées séparées — § 2.12). ### Contrat de sérialisation (RETEX M1 — 3 maillons) Groupes posés sur l'entité (source unique) : liste = `provider:read`+`category:read`+`site:read` ; détail = +`provider:item:read`. Piège booléen `isArchived` traité (`#[Groups]`+`#[SerializedName]` sur le getter). Embed `categories[].code/name` + `sites[].name/postalCode` (objet, pas IRI). ### Consommation cross-module (§ 2.1) - Site/Category via contrats Shared (`SiteInterface`/`CategoryInterface` + `resolve_target_entities`) — comme Supplier, conforme règle ABSOLUE n°1. - Référentiels comptables (`TvaMode`/`PaymentDelay`/`PaymentType`/`Bank`) en relation ORM partagée directe (décision § 2.1, remontée Shared tracée HP-M4-2). ### Garde-fous / infra (requis pour le vert) - Mapping ORM du module `Technique` dans `doctrine.yaml` (sinon les 9 tables `provider*` vues orphelines → DROP). - Tables `provider*` ajoutées à `ColumnCommentsCatalog` + ligne `dbal:run-sql uq_provider_company_name_active` au makefile `test-db-setup`. - 4 libellés `audit.entity.technique_*` (fr.json) ; `ProviderAddress::postalCode` whitelisté dans `EXCLUDED_LENGTH_MIRROR` (Regex CP {4,5}). ## Hors périmètre (→ ERP-134) ApiResource **sans** `ProviderProvider`/`ProviderProcessor` ; sous-entités **sans** `#[ApiResource]`. Hydratation effective, gating accounting, cloisonnement par site, normalisation, 409 doublon, RG-3.07/3.08 → ERP-134. Sous-ressources POST/PATCH/DELETE → ticket ultérieur. ## Tests - \`make test\` → **589/589 ✓** · \`php-cs-fixer\` → 0 correction. - \`schema:validate\` : mapping OK ; « not in sync » résiduel strictement homologue à supplier (COMMENT via catalogue + index FK auto-Doctrine), non régressif. --------- Co-authored-by: Matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/91 --- config/packages/doctrine.yaml | 10 + frontend/i18n/locales/fr.json | 6 +- makefile | 1 + .../Technique/Domain/Entity/Provider.php | 555 ++++++++++++++++++ .../Domain/Entity/ProviderAddress.php | 314 ++++++++++ .../Domain/Entity/ProviderContact.php | 189 ++++++ .../Technique/Domain/Entity/ProviderRib.php | 146 +++++ .../ProviderRepositoryInterface.php | 83 +++ .../Doctrine/DoctrineProviderRepository.php | 267 +++++++++ .../Database/ColumnCommentsCatalog.php | 91 ++- ...EntityConstraintsHaveFrenchMessageTest.php | 2 + 11 files changed, 1656 insertions(+), 8 deletions(-) create mode 100644 src/Module/Technique/Domain/Entity/Provider.php create mode 100644 src/Module/Technique/Domain/Entity/ProviderAddress.php create mode 100644 src/Module/Technique/Domain/Entity/ProviderContact.php create mode 100644 src/Module/Technique/Domain/Entity/ProviderRib.php create mode 100644 src/Module/Technique/Domain/Repository/ProviderRepositoryInterface.php create mode 100644 src/Module/Technique/Infrastructure/Doctrine/DoctrineProviderRepository.php diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 5aeb29c..a6a4377 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -80,6 +80,16 @@ doctrine: dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity' prefix: 'App\Module\Commercial\Domain\Entity' alias: Commercial + # Mapping inconditionnel du module Technique (meme logique que Commercial) : + # les tables prestataires (provider + sous-collections + jointures M2M) + # creees par la migration M3 (Version20260612100000) doivent etre connues + # de l'ORM. L'activation fonctionnelle passe par config/modules.php. + Technique: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/Module/Technique/Domain/Entity' + prefix: 'App\Module\Technique\Domain\Entity' + alias: Technique controller_resolver: auto_mapping: false diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index cd6a8ad..92f783e 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -416,7 +416,11 @@ "commercial_supplier": "Fournisseur", "commercial_supplieraddress": "Adresse fournisseur", "commercial_suppliercontact": "Contact fournisseur", - "commercial_supplierrib": "RIB fournisseur" + "commercial_supplierrib": "RIB fournisseur", + "technique_provider": "Prestataire", + "technique_provideraddress": "Adresse prestataire", + "technique_providercontact": "Contact prestataire", + "technique_providerrib": "RIB prestataire" }, "empty": "Aucune activité enregistrée", "no_results": "Aucun résultat pour ces filtres", diff --git a/makefile b/makefile index b749e4b..49dfc12 100644 --- a/makefile +++ b/makefile @@ -231,6 +231,7 @@ test-db-setup: $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" + $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" fixtures: $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load diff --git a/src/Module/Technique/Domain/Entity/Provider.php b/src/Module/Technique/Domain/Entity/Provider.php new file mode 100644 index 0000000..1d9a5e0 --- /dev/null +++ b/src/Module/Technique/Domain/Entity/Provider.php @@ -0,0 +1,555 @@ += 1). Nouveau vs supplier + * (qui n'avait des sites que sur l'adresse). Sert aussi le cloisonnement par + * site (§ 2.13, ticket Provider/Processor ERP-134). + * + * Referentiels comptables (TvaMode / PaymentDelay / PaymentType / Bank) et Site / + * Category : consommes en RELATION ORM PARTAGEE (decision Matthieu, § 2.1). Site / + * Category passent par les contrats Shared (SiteInterface / CategoryInterface + + * resolve_target_entities) comme le fait deja Supplier (regle ABSOLUE n°1). Les 4 + * referentiels comptables vivent dans le module Commercial et sont references en + * direct, faute de contrat Shared dedie (remontee dans Shared tracee HP-M4-2) — + * reference de donnees de reference, pas de logique inter-module. + * + * Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) : les read-groups + * sont poses ICI (source unique). L'#[ApiResource] est ici un SQUELETTE (operations + * + contextes + security) ; le ProviderProvider (liste paginee anti-N+1, exclusion + * archives, cloisonnement site, gating accounting) et le ProviderProcessor + * (normalisation, archivage, 409 doublon, RG-3.07 / RG-3.08) sont cables au ticket + * suivant (ERP-134) — ils ne sont volontairement PAS references ici. + * + * Audite (#[Auditable], tous champs — y compris RIB embarques, § 2.7) + + * Timestampable / Blamable via le trait Shared. + */ +#[ApiResource( + operations: [ + new GetCollection( + security: "is_granted('technique.providers.view')", + // La liste embarque les categories (code/name, groupe category:read) et + // les sites du prestataire (name/postalCode, groupe site:read — relation + // DIRECTE provider.sites, RG-3.03). Maillon (c) : category:read + + // site:read presents dans le contexte. L'hydratation anti-N+1 sera + // cablee par le ProviderProvider (ERP-134, cf. DoctrineProviderRepository). + normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']], + ), + new Get( + security: "is_granted('technique.providers.view')", + // Detail : prestataire + sous-collections embarquees (contacts, adresses + // + leurs sites/categories/contacts) + RIB (gates compta). Le groupe + // provider:read:accounting est volontairement ABSENT : il sera ajoute au + // contexte par le ProviderProvider / ReadGroupContextBuilder selon la + // permission accounting.view (ERP-134, parade fuite IBAN/BIC — bug #4 M1). + normalizationContext: ['groups' => [ + 'provider:read', + 'provider:item:read', + 'category:read', + 'site:read', + 'default:read', + ]], + ), + new Post( + security: "is_granted('technique.providers.manage')", + normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']], + denormalizationContext: ['groups' => ['provider:write:main']], + ), + new Patch( + // Security elargie : `manage` OU `accounting.manage` — le role Compta n'a + // pas `manage` global mais doit pouvoir editer l'onglet Comptabilite d'un + // prestataire existant (§ 2.9). Le re-gating onglet par onglet (mode strict + // RG-3.15) est porte par le ProviderProcessor (ERP-134). + security: "is_granted('technique.providers.manage') or is_granted('technique.providers.accounting.manage')", + normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']], + denormalizationContext: ['groups' => [ + 'provider:write:main', + 'provider:write:accounting', + 'provider:write:archive', + ]], + ), + // Pas de Delete au M3 (HP M4). Archivage via PATCH { isArchived: true }. + ], +)] +#[ORM\Entity(repositoryClass: DoctrineProviderRepository::class)] +#[ORM\Table(name: 'provider')] +// Index nommes pour matcher la migration (Version20260612100000). L'index unique +// partiel uq_provider_company_name_active reste possede par la migration : Doctrine +// ORM ne sait pas exprimer un index fonctionnel (LOWER) + partiel (WHERE) via +// attribut. Pas de #[ORM\UniqueConstraint] (§ 2.6). +#[ORM\Index(name: 'idx_provider_is_archived', columns: ['is_archived'])] +#[ORM\Index(name: 'idx_provider_deleted_at', columns: ['deleted_at'])] +#[ORM\Index(name: 'idx_provider_created_by', columns: ['created_by'])] +#[ORM\Index(name: 'idx_provider_updated_by', columns: ['updated_by'])] +#[Auditable] +class Provider implements TimestampableInterface, BlamableInterface +{ + use TimestampableBlamableTrait; + + /** + * RG-3.09 : seules les categories PORTANT ce type sont autorisees sur le + * prestataire (entite principale) ET sur ses adresses. Miroir de + * ProviderAddress. 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] + #[Groups(['provider:read'])] + private ?int $id = null; + + // === Formulaire principal === + #[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')] + #[Groups(['provider:read', 'provider:write:main'])] + private ?string $companyName = null; + + // RG-3.09 : au moins une categorie (Count min 1), de type PRESTATAIRE (verifie + // par validateCategoryType). M2M vers Category via le contrat CategoryInterface + // (resolve_target_entities -> Category). Embarquee en LISTE ET DETAIL ; maillon + // (c) : le contexte inclut 'category:read' pour exposer id/code/name. + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: CategoryInterface::class)] + #[ORM\JoinTable(name: 'provider_category')] + #[ORM\JoinColumn(name: 'provider_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:read', 'provider:write:main'])] + private Collection $categories; + + // RG-3.03 (SPECIFICITE M3) : au moins un site (Count min 1). Sites rattaches + // DIRECTEMENT au prestataire sur le formulaire principal (le fournisseur n'avait + // des sites que sur l'adresse). M2M vers Site via le contrat SiteInterface + // (resolve_target_entities -> Site). Embarquee en LISTE ET DETAIL ; maillon (c) : + // le contexte inclut 'site:read' pour exposer name/postalCode (Site n'a pas de + // `code`). L'ecriture cloisonnee par user_site (§ 2.13) est portee par le + // ProviderProcessor (ERP-134). + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: SiteInterface::class)] + #[ORM\JoinTable(name: 'provider_site')] + #[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] + #[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')] + #[Groups(['provider:read', 'provider:write:main'])] + private Collection $sites; + + // === Onglet Comptabilite === + // Lecture conditionnee via le groupe `provider:read:accounting` (ajoute au + // contexte par le ProviderProvider / ReadGroupContextBuilder si l'user a + // accounting.view — ERP-134). Ecriture via `provider:write:accounting` (le + // Processor exige accounting.manage). + #[ORM\Column(length: 20, nullable: true)] + #[Assert\Length(max: 20, maxMessage: 'Le SIREN ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?string $siren = null; + + #[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')] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?string $accountNumber = null; + + #[ORM\ManyToOne(targetEntity: TvaMode::class)] + #[ORM\JoinColumn(name: 'tva_mode_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?TvaMode $tvaMode = null; + + #[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')] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?string $nTva = null; + + #[ORM\ManyToOne(targetEntity: PaymentDelay::class)] + #[ORM\JoinColumn(name: 'payment_delay_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?PaymentDelay $paymentDelay = null; + + #[ORM\ManyToOne(targetEntity: PaymentType::class)] + #[ORM\JoinColumn(name: 'payment_type_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?PaymentType $paymentType = null; + + #[ORM\ManyToOne(targetEntity: Bank::class)] + #[ORM\JoinColumn(name: 'bank_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?Bank $bank = null; + + // === Sous-collections — EMBARQUEES dans le DETAIL (RETEX M1 §2) === + // Maillon (a) : le read-group est porte par le GETTER (getContacts / getAddresses + // / getRibs) — sans #[Groups], jamais serialisees. Edition via sous-ressources + // (ticket ulterieur M3). + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $contacts; + + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $addresses; + + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $ribs; + + // === Archive / Soft delete === + // Groupe d'ECRITURE uniquement sur la propriete (denormalisation PATCH archive). + // Le groupe de LECTURE est declare sur le getter isArchived() avec + // SerializedName('isArchived') : sans cela, Symfony strip le prefixe "is" et + // exposerait la cle JSON "archived" — en pratique la cle est totalement DROPPEE + // (piege n°3 du M1). Pattern corrige : Groups + SerializedName sur le getter. + #[ORM\Column(name: 'is_archived', options: ['default' => false])] + #[Groups(['provider:write:archive'])] + private bool $isArchived = false; + + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + #[Groups(['provider:read'])] + private ?DateTimeImmutable $archivedAt = null; + + // Soft delete technique (HP M4) : non expose en lecture/ecriture au M3. + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + private ?DateTimeImmutable $deletedAt = null; + + public function __construct() + { + $this->categories = new ArrayCollection(); + $this->sites = new ArrayCollection(); + $this->contacts = new ArrayCollection(); + $this->addresses = new ArrayCollection(); + $this->ribs = new ArrayCollection(); + } + + /** + * 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 + * 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, sur POST (categories ∈ provider:write:main) comme sur PATCH. + */ + #[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; + } + } + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCompanyName(): ?string + { + return $this->companyName; + } + + public function setCompanyName(string $companyName): static + { + $this->companyName = $companyName; + + return $this; + } + + /** @return Collection */ + 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; + } + + /** @return Collection */ + public function getSites(): Collection + { + return $this->sites; + } + + public function addSite(SiteInterface $site): static + { + if (!$this->sites->contains($site)) { + $this->sites->add($site); + } + + return $this; + } + + public function removeSite(SiteInterface $site): static + { + $this->sites->removeElement($site); + + return $this; + } + + public function getSiren(): ?string + { + return $this->siren; + } + + public function setSiren(?string $siren): static + { + $this->siren = $siren; + + return $this; + } + + public function getAccountNumber(): ?string + { + return $this->accountNumber; + } + + public function setAccountNumber(?string $accountNumber): static + { + $this->accountNumber = $accountNumber; + + return $this; + } + + public function getTvaMode(): ?TvaMode + { + return $this->tvaMode; + } + + public function setTvaMode(?TvaMode $tvaMode): static + { + $this->tvaMode = $tvaMode; + + return $this; + } + + public function getNTva(): ?string + { + return $this->nTva; + } + + public function setNTva(?string $nTva): static + { + $this->nTva = $nTva; + + return $this; + } + + public function getPaymentDelay(): ?PaymentDelay + { + return $this->paymentDelay; + } + + public function setPaymentDelay(?PaymentDelay $paymentDelay): static + { + $this->paymentDelay = $paymentDelay; + + return $this; + } + + public function getPaymentType(): ?PaymentType + { + return $this->paymentType; + } + + public function setPaymentType(?PaymentType $paymentType): static + { + $this->paymentType = $paymentType; + + return $this; + } + + public function getBank(): ?Bank + { + return $this->bank; + } + + public function setBank(?Bank $bank): static + { + $this->bank = $bank; + + return $this; + } + + /** @return Collection */ + #[Groups(['provider:item:read'])] + public function getContacts(): Collection + { + return $this->contacts; + } + + public function addContact(ProviderContact $contact): static + { + if (!$this->contacts->contains($contact)) { + $this->contacts->add($contact); + $contact->setProvider($this); + } + + return $this; + } + + public function removeContact(ProviderContact $contact): static + { + if ($this->contacts->removeElement($contact) && $contact->getProvider() === $this) { + $contact->setProvider(null); + } + + return $this; + } + + /** @return Collection */ + #[Groups(['provider:item:read'])] + public function getAddresses(): Collection + { + return $this->addresses; + } + + public function addAddress(ProviderAddress $address): static + { + if (!$this->addresses->contains($address)) { + $this->addresses->add($address); + $address->setProvider($this); + } + + return $this; + } + + public function removeAddress(ProviderAddress $address): static + { + if ($this->addresses->removeElement($address) && $address->getProvider() === $this) { + $address->setProvider(null); + } + + return $this; + } + + // Embed gate sur le groupe COMPTABLE (et non provider:item:read comme contacts/ + // adresses) : provider:read:accounting n'est ajoute au contexte que si l'user a + // accounting.view (ProviderProvider / ReadGroupContextBuilder, ERP-134). Resultat : + // la cle `ribs` est TOTALEMENT ABSENTE du detail pour un user sans accounting.view + // (ex. Commerciale), au meme titre que les scalaires comptables — evite la fuite + // IBAN/BIC (piege n°4 M1). + /** @return Collection */ + #[Groups(['provider:read:accounting'])] + public function getRibs(): Collection + { + return $this->ribs; + } + + public function addRib(ProviderRib $rib): static + { + if (!$this->ribs->contains($rib)) { + $this->ribs->add($rib); + $rib->setProvider($this); + } + + return $this; + } + + public function removeRib(ProviderRib $rib): static + { + if ($this->ribs->removeElement($rib) && $rib->getProvider() === $this) { + $rib->setProvider(null); + } + + return $this; + } + + // Groupe de lecture + nom serialise explicite : sans SerializedName, Symfony + // exposerait la cle "archived" (strip du prefixe "is" sur les getters) et + // droppait silencieusement la cle du JSON (piege n°3 du M1). + #[Groups(['provider:read'])] + #[SerializedName('isArchived')] + public function isArchived(): bool + { + return $this->isArchived; + } + + public function setIsArchived(bool $isArchived): static + { + $this->isArchived = $isArchived; + + return $this; + } + + public function getArchivedAt(): ?DateTimeImmutable + { + return $this->archivedAt; + } + + public function setArchivedAt(?DateTimeImmutable $archivedAt): static + { + $this->archivedAt = $archivedAt; + + return $this; + } + + public function getDeletedAt(): ?DateTimeImmutable + { + return $this->deletedAt; + } + + public function setDeletedAt(?DateTimeImmutable $deletedAt): static + { + $this->deletedAt = $deletedAt; + + return $this; + } +} diff --git a/src/Module/Technique/Domain/Entity/ProviderAddress.php b/src/Module/Technique/Domain/Entity/ProviderAddress.php new file mode 100644 index 0000000..4b26a5d --- /dev/null +++ b/src/Module/Technique/Domain/Entity/ProviderAddress.php @@ -0,0 +1,314 @@ + 'France'])] + #[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['provider:item:read', 'provider:write:addresses'])] + private string $country = 'France'; + + // RG-3.06 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur). + // Le Regex borne deja la longueur (<= 5) : pas de Length redondant (whitelist + // ERP-107). + #[ORM\Column(length: 20)] + #[Assert\NotBlank(message: 'Le code postal est obligatoire.', normalizer: 'trim')] + #[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')] + #[Groups(['provider:item:read', 'provider:write:addresses'])] + private ?string $postalCode = null; + + #[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')] + #[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')] + #[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')] + #[Groups(['provider:item:read', 'provider:write:addresses'])] + private ?string $streetComplement = null; + + // Ordre d'affichage de l'adresse (gere serveur, non expose au M3). + #[ORM\Column(options: ['default' => 0])] + private int $position = 0; + + // RG-3.05 : au moins un site rattache a chaque adresse. + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: SiteInterface::class)] + #[ORM\JoinTable(name: 'provider_address_site')] + #[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] + #[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')] + #[Groups(['provider:item:read', 'provider:write:addresses'])] + private Collection $sites; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: ProviderContact::class)] + #[ORM\JoinTable(name: 'provider_address_contact')] + #[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'provider_contact_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[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 */ + #[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; + } + } + } + + public function getId(): ?int + { + return $this->id; + } + + public function getProvider(): ?Provider + { + return $this->provider; + } + + public function setProvider(?Provider $provider): static + { + $this->provider = $provider; + + return $this; + } + + public function getCountry(): string + { + return $this->country; + } + + public function setCountry(string $country): static + { + $this->country = $country; + + return $this; + } + + public function getPostalCode(): ?string + { + return $this->postalCode; + } + + public function setPostalCode(?string $postalCode): static + { + $this->postalCode = $postalCode; + + return $this; + } + + public function getCity(): ?string + { + return $this->city; + } + + public function setCity(?string $city): static + { + $this->city = $city; + + return $this; + } + + public function getStreet(): ?string + { + return $this->street; + } + + public function setStreet(?string $street): static + { + $this->street = $street; + + return $this; + } + + public function getStreetComplement(): ?string + { + return $this->streetComplement; + } + + public function setStreetComplement(?string $streetComplement): static + { + $this->streetComplement = $streetComplement; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } + + /** @return Collection */ + public function getSites(): Collection + { + return $this->sites; + } + + public function addSite(SiteInterface $site): static + { + if (!$this->sites->contains($site)) { + $this->sites->add($site); + } + + return $this; + } + + public function removeSite(SiteInterface $site): static + { + $this->sites->removeElement($site); + + return $this; + } + + /** @return Collection */ + public function getContacts(): Collection + { + return $this->contacts; + } + + public function addContact(ProviderContact $contact): static + { + if (!$this->contacts->contains($contact)) { + $this->contacts->add($contact); + } + + return $this; + } + + public function removeContact(ProviderContact $contact): static + { + $this->contacts->removeElement($contact); + + return $this; + } + + /** @return Collection */ + 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; + } +} diff --git a/src/Module/Technique/Domain/Entity/ProviderContact.php b/src/Module/Technique/Domain/Entity/ProviderContact.php new file mode 100644 index 0000000..9abe03e --- /dev/null +++ b/src/Module/Technique/Domain/Entity/ProviderContact.php @@ -0,0 +1,189 @@ + 0])] + private int $position = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getProvider(): ?Provider + { + return $this->provider; + } + + public function setProvider(?Provider $provider): static + { + $this->provider = $provider; + + return $this; + } + + public function getFirstName(): ?string + { + return $this->firstName; + } + + public function setFirstName(?string $firstName): static + { + $this->firstName = $firstName; + + return $this; + } + + public function getLastName(): ?string + { + return $this->lastName; + } + + public function setLastName(?string $lastName): static + { + $this->lastName = $lastName; + + return $this; + } + + public function getJobTitle(): ?string + { + return $this->jobTitle; + } + + public function setJobTitle(?string $jobTitle): static + { + $this->jobTitle = $jobTitle; + + return $this; + } + + public function getPhonePrimary(): ?string + { + return $this->phonePrimary; + } + + public function setPhonePrimary(?string $phonePrimary): static + { + $this->phonePrimary = $phonePrimary; + + return $this; + } + + public function getPhoneSecondary(): ?string + { + return $this->phoneSecondary; + } + + public function setPhoneSecondary(?string $phoneSecondary): static + { + $this->phoneSecondary = $phoneSecondary; + + return $this; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(?string $email): static + { + $this->email = $email; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } +} diff --git a/src/Module/Technique/Domain/Entity/ProviderRib.php b/src/Module/Technique/Domain/Entity/ProviderRib.php new file mode 100644 index 0000000..c400c67 --- /dev/null +++ b/src/Module/Technique/Domain/Entity/ProviderRib.php @@ -0,0 +1,146 @@ + 0])] + private int $position = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getProvider(): ?Provider + { + return $this->provider; + } + + public function setProvider(?Provider $provider): static + { + $this->provider = $provider; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): static + { + $this->label = $label; + + return $this; + } + + public function getBic(): ?string + { + return $this->bic; + } + + public function setBic(string $bic): static + { + $this->bic = $bic; + + return $this; + } + + public function getIban(): ?string + { + return $this->iban; + } + + public function setIban(string $iban): static + { + $this->iban = $iban; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): static + { + $this->position = $position; + + return $this; + } +} diff --git a/src/Module/Technique/Domain/Repository/ProviderRepositoryInterface.php b/src/Module/Technique/Domain/Repository/ProviderRepositoryInterface.php new file mode 100644 index 0000000..d5541a7 --- /dev/null +++ b/src/Module/Technique/Domain/Repository/ProviderRepositoryInterface.php @@ -0,0 +1,83 @@ + uniquement les archives (is_archived = true) ; + * - sinon $includeArchived = true -> actifs + archives (echappatoire) ; + * - sinon (defaut) -> uniquement les actifs (is_archived = false). + * $archivedOnly a la priorite sur $includeArchived. + * - Tri par defaut : companyName ASC (RG-3.16). + * - $search : recherche fuzzy insensible a la casse sur companyName + les + * contacts lies (firstName / lastName / email) via sous-requete. + * Metacaracteres LIKE echappes. Ignore si null/vide. + * - $categoryCodes : restreint aux prestataires possedant au moins une + * categorie dont le code est dans la liste (OR). Liste vide = pas de filtre. + * - $siteIds : restreint aux prestataires rattaches a l'un des sites donnes + * (OR — RG-3.03, relation DIRECTE provider.sites). Liste vide = pas de filtre. + * + * Filtrage centralise ICI (et non dans le provider/controller) pour que la + * liste paginee et l'export partagent strictement la meme logique de selection + * (miroir M2). + * + * Contrat = SELECTION uniquement (filtres + tri). Aucun fetch-join to-many : + * l'hydratation des collections affichees est deleguee a + * {@see self::hydrateListCollections()} pour ne pas imposer le cout d'un + * produit cartesien aux chemins non pagines (§ 2.12, cf. M1/ERP-100, M2). + * + * NB : le cloisonnement par site pilote par l'utilisateur (RG-3.17, § 2.13) est + * applique en AMONT par le ProviderProvider (ERP-134), pas par ce QueryBuilder + * (qui ne connait pas l'user courant). + * + * @param list $categoryCodes + * @param list $siteIds + */ + public function createListQueryBuilder( + bool $includeArchived = false, + ?string $search = null, + array $categoryCodes = [], + array $siteIds = [], + bool $archivedOnly = false, + ): QueryBuilder; + + /** + * Hydrate en lot les collections affichees par le repertoire (categories puis + * sites — relation DIRECTE provider.sites, RG-3.03) sur un jeu de prestataires + * DEJA charges, via l'identity map Doctrine (memes instances). A appeler apres + * une selection bornee (page courante ou jeu d'export) pour eviter le N+1 a la + * serialisation, sans imposer de fetch-join au QueryBuilder de selection + * (anti N+1, § 2.12). + * + * Charge les categories et les sites en DEUX requetes distinctes (et non un + * double fetch-join) pour ne pas multiplier categories x sites en un seul + * produit cartesien. + * + * @param list $providers + */ + public function hydrateListCollections(array $providers): void; + + /** + * Hydrate en lot la collection `contacts` sur un jeu de prestataires DEJA + * charges (memes instances via l'identity map). Reservee aux chemins qui ont + * besoin du contact principal (export) : la LISTE paginee n'embarque pas les + * contacts (§ 2.12), d'ou une methode dediee plutot qu'une passe supplementaire + * dans {@see self::hydrateListCollections()}. + * + * @param list $providers + */ + public function hydrateContacts(array $providers): void; +} diff --git a/src/Module/Technique/Infrastructure/Doctrine/DoctrineProviderRepository.php b/src/Module/Technique/Infrastructure/Doctrine/DoctrineProviderRepository.php new file mode 100644 index 0000000..ec63c4e --- /dev/null +++ b/src/Module/Technique/Infrastructure/Doctrine/DoctrineProviderRepository.php @@ -0,0 +1,267 @@ + + */ +class DoctrineProviderRepository extends ServiceEntityRepository implements ProviderRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Provider::class); + } + + public function findById(int $id): ?Provider + { + return $this->find($id); + } + + public function save(Provider $provider): void + { + $this->getEntityManager()->persist($provider); + $this->getEntityManager()->flush(); + } + + public function createListQueryBuilder( + bool $includeArchived = false, + ?string $search = null, + array $categoryCodes = [], + array $siteIds = [], + bool $archivedOnly = false, + ): QueryBuilder { + // SELECTION uniquement (filtres + tri) : pas de fetch-join to-many ici. + // L'hydratation des collections affichees (Catégories / Site(s)) est + // deleguee a hydrateListCollections() une fois le jeu borne, pour ne pas + // imposer un produit cartesien aux chemins non pagines (export, + // ?pagination=false) — § 2.12 (cf. M1/ERP-100, M2). + $qb = $this->createQueryBuilder('p') + ->andWhere('p.deletedAt IS NULL') + ->orderBy('p.companyName', 'ASC') + ; + + // Perimetre d'archivage : archivedOnly prioritaire sur includeArchived. + if ($archivedOnly) { + $qb->andWhere('p.isArchived = true'); + } elseif (!$includeArchived) { + $qb->andWhere('p.isArchived = false'); + } + + $this->applySearch($qb, $search); + $this->applyCategoryCodes($qb, $categoryCodes); + $this->applySiteIds($qb, $siteIds); + + return $qb; + } + + public function hydrateListCollections(array $providers): void + { + $ids = $this->collectIds($providers); + if ([] === $ids) { + return; + } + + // 1re passe : categories (colonne « Catégories »). Produit p x cat seul. + $this->createQueryBuilder('p') + ->leftJoin('p.categories', 'cat')->addSelect('cat') + ->where('p.id IN (:ids)')->setParameter('ids', $ids) + ->getQuery() + ->getResult() + ; + + // 2e passe : sites (colonne « Site(s) »). SPECIFICITE M3 : les sites sont + // portes DIRECTEMENT par le prestataire (provider.sites, RG-3.03), pas via + // les adresses comme au M2 — d'ou un simple join p -> site (pas d'imbrication + // addr -> site). Separer des categories casse le cartesien cat x site. + $this->createQueryBuilder('p') + ->leftJoin('p.sites', 'site')->addSelect('site') + ->where('p.id IN (:ids)')->setParameter('ids', $ids) + ->getQuery() + ->getResult() + ; + } + + public function hydrateContacts(array $providers): void + { + $ids = $this->collectIds($providers); + if ([] === $ids) { + return; + } + + // Une seule requete IN bornee : remplit la collection `contacts` des MEMES + // instances Provider (identity map). Tri par position pour que le « contact + // principal » (plus petit position) soit deterministe a l'export. + $this->createQueryBuilder('p') + ->leftJoin('p.contacts', 'pc')->addSelect('pc') + ->where('p.id IN (:ids)')->setParameter('ids', $ids) + ->orderBy('pc.position', 'ASC') + ->getQuery() + ->getResult() + ; + } + + /** + * Recherche fuzzy insensible a la casse sur companyName ET sur les contacts + * lies (firstName / lastName / email) — miroir M2. Les deux criteres sont unis + * par OR : un prestataire matche si son nom de societe OU l'un de ses contacts + * matche. Le critere contact passe par une sous-requete IN (plutot qu'un JOIN + * sur la collection) pour ne pas perturber le DISTINCT / ORDER BY / pagination + * principal. Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester + * litteraux. + */ + private function applySearch(QueryBuilder $qb, ?string $search): void + { + if (null === $search || '' === trim($search)) { + return; + } + + $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search)); + $pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%'; + + $contactSub = $this->getEntityManager()->createQueryBuilder() + ->select('p2.id') + ->from(Provider::class, 'p2') + ->join('p2.contacts', 'pc2') + ->where('LOWER(pc2.firstName) LIKE :search') + ->orWhere('LOWER(pc2.lastName) LIKE :search') + ->orWhere('LOWER(pc2.email) LIKE :search') + ; + + $qb->andWhere( + $qb->expr()->orX( + 'LOWER(p.companyName) LIKE :search', + $qb->expr()->in('p.id', $contactSub->getDQL()), + ), + )->setParameter('search', $pattern); + } + + /** + * Restreint aux prestataires possedant au moins une categorie dont le code + * figure dans la liste (OR). Alimente le filtre « Catégories » du drawer. + * Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas + * perturber le DISTINCT / ORDER BY principal. + * + * @param list $categoryCodes + */ + private function applyCategoryCodes(QueryBuilder $qb, array $categoryCodes): void + { + $codes = $this->normalizeStringList($categoryCodes); + if ([] === $codes) { + return; + } + + $sub = $this->getEntityManager()->createQueryBuilder() + ->select('p3.id') + ->from(Provider::class, 'p3') + ->join('p3.categories', 'cat3') + ->where('cat3.code IN (:categoryCodes)') + ; + + $qb->andWhere($qb->expr()->in('p.id', $sub->getDQL())) + ->setParameter('categoryCodes', $codes) + ; + } + + /** + * Restreint aux prestataires rattaches a l'un des sites donnes (OR). SPECIFICITE + * M3 : les sites sont portes DIRECTEMENT par le prestataire (provider.sites, + * RG-3.03), d'ou une sous-requete sur p.sites (et non sur les adresses comme au + * M2). Sous-requete IN pour ne pas perturber le tri/pagination principal. + * + * @param list $siteIds + */ + private function applySiteIds(QueryBuilder $qb, array $siteIds): void + { + $ids = $this->normalizeIntList($siteIds); + if ([] === $ids) { + return; + } + + $sub = $this->getEntityManager()->createQueryBuilder() + ->select('p4.id') + ->from(Provider::class, 'p4') + ->join('p4.sites', 'site4') + ->where('site4.id IN (:siteIds)') + ; + + $qb->andWhere($qb->expr()->in('p.id', $sub->getDQL())) + ->setParameter('siteIds', $ids) + ; + } + + /** + * Extrait les identifiants non nuls d'un jeu de prestataires (entites managees). + * Les requetes d'hydratation renvoient les MEMES instances Provider (identity + * map), dont les collections sont alors remplies — anti N+1 a la serialisation. + * + * @param list $providers + * + * @return list + */ + private function collectIds(array $providers): array + { + $ids = []; + foreach ($providers as $provider) { + $id = $provider->getId(); + if (null !== $id) { + $ids[] = $id; + } + } + + return $ids; + } + + /** + * Nettoie une liste de chaines : trim, retrait des vides, reindexation. + * Defensive : tolere des elements scalaires non-string (cast) et ignore le + * reste sans lever de TypeError, le contrat etant de normaliser une entree + * potentiellement brute (query params). + * + * @param array $values + * + * @return list + */ + private function normalizeStringList(array $values): array + { + $out = []; + foreach ($values as $value) { + if (is_string($value) || is_int($value) || is_float($value)) { + $trimmed = trim((string) $value); + if ('' !== $trimmed) { + $out[] = $trimmed; + } + } + } + + return $out; + } + + /** + * Nettoie une liste d'identifiants : cast int, retrait des <= 0, reindexation. + * Defensive (cf. normalizeStringList) : accepte des entiers ou des chaines + * numeriques ('1', '2') sans TypeError, ignore le reste. + * + * @param array $values + * + * @return list + */ + private function normalizeIntList(array $values): array + { + $out = []; + foreach ($values as $value) { + if (is_numeric($value) && (int) $value > 0) { + $out[] = (int) $value; + } + } + + return $out; + } +} diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index c034d04..13f5770 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -362,13 +362,90 @@ final class ColumnCommentsCatalog 'position' => 'Ordre d affichage du RIB dans la liste du fournisseur (croissant).', ] + self::timestampableBlamableComments(), - // NB : les tables provider* (M3 Technique) NE SONT PAS encore au - // catalogue. Tant que les entites Provider* n existent pas (ERP-133), - // `schema:update --force` du setup de test droppe ces tables non - // mappees ; les referencer ici ferait planter `app:apply-column-comments` - // (table absente en test). La migration ERP-132 porte ses COMMENT inline - // (dev/prod). Le catalogue sera etendu au ticket entites (ERP-133), - // comme l a fait supplier (ERP-86) apres sa migration (ERP-85). + // Tables provider* (M3 Technique) — ajoutees au ticket entites (ERP-133), + // comme l a fait supplier (ERP-86) apres sa migration (ERP-85). En test, + // `schema:update --force` recree ces tables depuis le mapping ORM (sans + // COMMENT) ; `app:apply-column-comments` les repose depuis ce catalogue. + 'provider' => [ + '_table' => 'Repertoire prestataires (M3 Technique) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M4). Pas d onglet Information (≠ supplier).', + 'id' => 'Identifiant interne auto-incremente.', + 'company_name' => 'Raison sociale du prestataire (stockee en MAJUSCULES). Unique case-insensitive parmi les actifs non archives/non supprimes (uq_provider_company_name_active, RG-3.10).', + 'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-3.10).', + 'account_number' => 'Onglet Comptabilite : numero de compte comptable du prestataire.', + 'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id (referentiel partage M1), ON DELETE RESTRICT.', + 'n_tva' => 'Onglet Comptabilite : numero de TVA intracommunautaire.', + 'payment_delay_id' => 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id (M1), ON DELETE RESTRICT.', + 'payment_type_id' => 'Onglet Comptabilite : type de reglement — FK -> payment_type.id (M1), ON DELETE RESTRICT. Pilote RG-3.07 (Banque si VIREMENT) et RG-3.08 (RIB).', + 'bank_id' => 'Onglet Comptabilite : banque — FK -> bank.id (M1), ON DELETE RESTRICT. Obligatoire ssi payment_type = VIREMENT (RG-3.07), null sinon.', + 'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission technique.providers.archive.', + 'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.', + 'deleted_at' => 'Horodatage du soft-delete technique (HP M4) — non expose par l API au M3. Null = ligne active.', + ] + self::timestampableBlamableComments(), + + 'provider_category' => [ + '_table' => 'Jointure M2M provider <-> category (Catalog) — categories de type PRESTATAIRE du prestataire, au moins une obligatoire (RG-3.09).', + 'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur de la categorie.', + 'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie de type PRESTATAIRE rattachee au prestataire (RG-3.09).', + ], + + 'provider_site' => [ + '_table' => 'Jointure M2M provider <-> site (Sites) — sites du prestataire, selecteur du formulaire principal, au moins un obligatoire (RG-3.03). Sert le cloisonnement par site (§ 2.13).', + 'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur du site.', + 'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache au prestataire (RG-3.03, idx_provider_site_site).', + ], + + 'provider_contact' => [ + '_table' => 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/telephone/email (RG-3.04, chk_provider_contact_name).', + 'id' => 'Identifiant interne auto-incremente.', + 'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.', + 'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).', + 'last_name' => 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).', + 'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).', + 'phone_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).', + 'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).', + 'email' => 'Email du contact (lowercase serveur).', + 'position' => 'Ordre d affichage du contact dans la liste du prestataire (croissant).', + ] + self::timestampableBlamableComments(), + + 'provider_address' => [ + '_table' => 'Adresses d un prestataire (1:n) — >= 1 site rattache (RG-3.05). SANS address_type / bennes / triage_provider (specifiques fournisseur).', + 'id' => 'Identifiant interne auto-incremente.', + 'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire de l adresse.', + 'country' => 'Pays de l adresse — defaut France.', + 'postal_code' => 'Code postal (4-5 chiffres attendus) — declenche l autocompletion ville via l API BAN cote front (RG-3.06).', + 'city' => 'Ville — preremplie depuis le code postal via API BAN cote front.', + 'street' => 'Numero et voie de l adresse.', + 'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.', + 'position' => 'Ordre d affichage de l adresse dans la liste du prestataire (croissant).', + ] + self::timestampableBlamableComments(), + + 'provider_address_site' => [ + '_table' => 'Jointure M2M provider_address <-> site (Sites) — sites rattaches a l adresse (>= 1 obligatoire, RG-3.05).', + 'provider_address_id' => 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.', + 'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.', + ], + + 'provider_address_contact' => [ + '_table' => 'Jointure M2M provider_address <-> provider_contact — contacts associes a une adresse.', + 'provider_address_id' => 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.', + '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.', + 'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du RIB.', + 'label' => 'Libelle du RIB (ex: compte principal).', + 'bic' => 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).', + 'iban' => 'IBAN du compte (≤ 34 caracteres).', + 'position' => 'Ordre d affichage du RIB dans la liste du prestataire (croissant).', + ] + self::timestampableBlamableComments(), ]; } diff --git a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php index fb5d338..1e2213f 100644 --- a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php +++ b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php @@ -54,6 +54,8 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase 'ClientAddress::postalCode' => 'Regex {4,5} borne deja la longueur.', // Idem cote fournisseur (meme Regex CP). 'SupplierAddress::postalCode' => 'Regex {4,5} borne deja la longueur.', + // Idem cote prestataire (meme Regex CP — M3 Technique). + 'ProviderAddress::postalCode' => 'Regex {4,5} borne deja la longueur.', // Le Choice {PROSPECT,DEPART,RENDU} borne les valeurs (<= 8 < 20). 'SupplierAddress::addressType' => 'Choice {PROSPECT,DEPART,RENDU} borne deja les valeurs.', // Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres.