Compare commits

..

7 Commits

Author SHA1 Message Date
tristan c888a45cc4 chore(transport) : User-Agent navigateur neutre pour les syncs externes
Remplace l'UA 'Starseed-ERP' par un UA navigateur neutre : évite les filtres anti-bot des sources (qualimat.org WordPress/WAF, icrt-idtf.com) sans révéler l'application.
2026-06-12 15:57:59 +02:00
tristan abe663d355 feat(transport) : synchronisation du référentiel codes IDTF (ERP-149)
Commande console app:idtf:sync : récupère l'export Excel des codes IDTF (régimes de nettoyage transport) depuis icrt-idtf.com, le parse et synchronise une table référentielle (upsert sur (schema, idtf_number) + soft-delete + journal). Scope road ; discriminant schema road/water conservé.

- migration : tables idtf_product + idtf_sync_log (COMMENT ON COLUMN sur chaque colonne, unique (schema, idtf_number), cas_numbers JSONB)
- IdtfSheetParser : parsing pur d'une matrice (détection dynamique de l'en-tête, mapping par libellé, CAS split, date dd-mm-yyyy -> ISO) + tests unitaires
- SyncIdtfCommand : options --schema / --file / --dry-run, POST avec fields[] explicites (export 11 colonnes), upsert DBAL transactionnel
- cible make idtf-sync
- tests fonctionnels via .xlsx généré (parsing/upsert/journal/soft-delete)

Réutilise framework.http_client (activé pour QUALIMAT, ERP-39). phpoffice/phpspreadsheet déjà présent.
2026-06-12 15:49:28 +02:00
tristan c8bff68373 chore(transport) : ajouter la cible make qualimat-sync (ERP-39) 2026-06-12 15:19:13 +02:00
tristan b444061237 feat(transport) : synchronisation du référentiel transporteurs QUALIMAT (ERP-39)
Commande console app:qualimat:sync : récupère les opérateurs de transport agréés depuis l'API publique qualimat.org, normalise et synchronise une table référentielle (upsert sur le SIRET + soft-delete des absents + journal). Prévue pour un cron quotidien.

- migration : tables qualimat_carrier + qualimat_sync_log (COMMENT ON COLUMN sur chaque colonne)
- QualimatRowMapper : normalisation pure (SIRET sans espaces, date dd/mm/yyyy -> ISO, skip sans SIRET) + tests unitaires
- SyncQualimatCommand : options --file / --ppp / --dry-run, upsert DBAL transactionnel
- activation de framework.http_client
- tests fonctionnels de la commande (upsert/normalisation/journal/soft-delete)
2026-06-12 15:03:28 +02:00
tristan 5f3da7022b feat(transport) : créer le module Transport (ERP-150)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m29s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m41s
Module Transport (ID transport, non requis) destiné à héberger les référentiels externes synchronisés par commandes console (codes IDTF ERP-149, transporteurs QUALIMAT ERP-39).

- TransportModule.php avec permissions() vide à ce stade
- activation dans config/modules.php
- layer Nuxt front minimal (pas d'écran ni d'item sidebar)
2026-06-12 14:35:16 +02:00
gitea-actions b36520d3b1 chore: bump version to v0.1.110
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 1m17s
2026-06-12 08:45:47 +00:00
tristan a340d8139a feat(commercial) : amélioration et validation stricte des champs date (ERP-148) (#92)
Auto Tag Develop / tag (push) Successful in 8s
## Contexte
ERP-148 — mise à jour @malio/layer-ui et amélioration des champs date (onglet Information, Client & Fournisseur).

## Changements
- **MalioDate v1.7.10** : le composant expose désormais son état de validité (`@update:valid`) et la saisie brute invalide (`@update:rawValue`).
- **Validation back-autoritaire du format** : `foundedAt` n'accepte plus que l'ISO strict `Y-m-d` (`#[Context]` DateTimeNormalizer) + `collectDenormalizationErrors` sur `Client` et `Supplier`. Toute saisie non-ISO renvoie un **422 porté sur le champ**.
  - Corrige un cas piège : `12/25/2026` (invalide en JJ/MM/AAAA côté front) était auparavant accepté par PHP en M/J/AAAA → 25 décembre. Désormais rejeté.
- **Front** : la saisie invalide est transmise au back ; le message technique de type-error est surchargé par une clé i18n via le **code de violation** (`resolveViolationMessage` / `VIOLATION_MESSAGE_I18N`), affiché inline par `useFormErrors`.
- Réorganisation des utils de formulaire sous `utils/forms/`.

## Tests
- Back : `ClientFoundedAtFormatTest` / `SupplierFoundedAtFormatTest` (dont le cas piège `12/25/2026`).
- Front : résolveur i18n (`api.test.ts`, `useFormErrors.test.ts`) + payloads (`clientEdit`/`supplierEdit` specs).
- Suite Commercial verte ; vérifié bout-en-bout en navigateur (PATCH → 422, erreur inline, submit bloqué).

## Note
Échecs JWT aléatoires connus du hook pre-commit (401/500 sur tests d'auth sans rapport) ; tous verts en isolation.

Reviewed-on: #92
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-12 08:45:38 +00:00
89 changed files with 2076 additions and 7993 deletions
+1
View File
@@ -79,6 +79,7 @@ Regles :
- **Toujours `{ toast: false }`** sur l'appel API qui veut un mapping inline (sinon le toast natif d'`useApi` masque le fin).
- **Cas metier specifique** (ex: 409 doublon) : `setError('champ', message)` + toast explicite **avant** de deleguer le reste a `handleApiError`. Cf. `useCategoryForm` (doublon RG-1.07).
- **Collections** (listes de sous-entites sauvees par un appel par ligne) : une erreur PAR LIGNE via un tableau `ref<Record<string, string>[]>` aligne sur l'index, peuple par `mapViolationsToRecord(error.response._data)` (util pur de `shared/utils/api.ts`). Le composant de ligne expose une prop `:errors` (`Record<string, string>`) bindee sur le `:error` de chaque champ. Cf. `ClientContactBlock` / `ClientAddressBlock` et les submits de `clients/new.vue` / `clients/[id]/edit.vue`.
- **Message back technique → surcharge i18n par code** : la plupart des contraintes back portent un message FR explicite (affiche tel quel). Mais une 422 peut porter un message TECHNIQUE non montrable (ex. erreur de type API Platform sur une date non parsable : « Cette valeur doit être de type DateTimeImmutable|null. », voire en anglais selon la negociation). On le surcharge **cote front** via le `code` de violation (UUID Symfony fige, robuste — pas un match sur le texte) : table `VIOLATION_MESSAGE_I18N` + `resolveViolationMessage` dans `shared/utils/api.ts`, appliquee par `useFormErrors`. Ajouter un cas = une entree `code -> cle i18n`. Cas reference : date invalide (MalioDate forwarde la saisie brute via `@update:rawValue`, le back renvoie 422 sur `foundedAt` grace a `collectDenormalizationErrors`, le front affiche `errors.validation.invalidDate`).
**Interdit** : se contenter d'un toast global sur une 422 quand le back identifie les champs fautifs (`propertyPath`). Reimplementer un mapping `if/else` par champ a la main au lieu d'`useFormErrors` / `mapViolationsToRecord`.
+2 -2
View File
@@ -5,12 +5,12 @@ use App\Module\Catalog\CatalogModule;
use App\Module\Commercial\CommercialModule;
use App\Module\Core\CoreModule;
use App\Module\Sites\SitesModule;
use App\Module\Technique\TechniqueModule;
use App\Module\Transport\TransportModule;
return [
CoreModule::class,
CommercialModule::class,
SitesModule::class,
CatalogModule::class,
TechniqueModule::class,
TransportModule::class,
];
-10
View File
@@ -80,16 +80,6 @@ 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
+13
View File
@@ -0,0 +1,13 @@
# Active le composant HTTP Client (symfony/http-client) et enregistre
# l'autowiring de HttpClientInterface. Utilise par les commandes de
# synchronisation de referentiels externes (QUALIMAT, IDTF...).
#
# User-Agent navigateur neutre : les sources (qualimat.org sous WordPress/WAF,
# icrt-idtf.com) filtrent souvent les UA de bibliotheque/vides ; un UA de type
# navigateur evite les blocages anti-bot sans reveler l'application.
framework:
http_client:
default_options:
timeout: 30
headers:
User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
-17
View File
@@ -61,23 +61,6 @@ return [
],
],
],
// Section "Technique" (M3, ERP-138) : pole distinct du Commercial, porte le
// repertoire prestataires. L'item est gate par `technique.providers.view` ;
// la section disparait automatiquement (SidebarProvider) si le module
// `technique` est desactive ou si l'user n'a pas la permission.
[
'label' => 'sidebar.technique.section',
'icon' => 'mdi:wrench-outline',
'items' => [
[
'label' => 'sidebar.technique.providers',
'to' => '/providers',
'icon' => 'mdi:account-wrench-outline',
'module' => 'technique',
'permission' => 'technique.providers.view',
],
],
],
// Section "Administration" : regroupe toutes les pages de configuration
// applicative (RBAC, users, sites, audit log).
//
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.109'
app.version: '0.1.110'
File diff suppressed because it is too large Load Diff
-339
View File
@@ -1,339 +0,0 @@
---
# === IDENTITÉ ===
module: M3
nom: "Répertoire prestataires"
ecran: repertoire-prestataires
owner_spec: Matthieu
backup_spec: Tristan
version: V0.2
date_redaction: 2026-06-11
# Historique :
# V0.2 (2026-06-11) — Restitution Markdown du docx « M3-reportoire-prestataires.docx » (04/06/2026).
# Alignement refonte-contact (comme M1/M2) : le contact principal inline du formulaire principal
# du PDF V0.1 (Nom contact / Prénom contact / Téléphone + / Email) est RETIRÉ — saisie via
# l'onglet Contacts uniquement (décision Matthieu, 11/06 : « oublie le contact inline, comme client »).
# RG-3.01 / RG-3.02 (contact inline + max 2 tél sur le formulaire principal) supprimées en conséquence.
# V0.1 (PDF) — version fonctionnelle plus ancienne, NON retenue (contact inline sur le formulaire principal).
# === LIENS ===
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-42090&p=f&m=dev"
regles_metier: [RG-3.03, RG-3.04, RG-3.05, RG-3.06, RG-3.07, RG-3.08, RG-3.09, RG-3.10, RG-3.11, RG-3.12, RG-3.13, RG-3.14, RG-3.15, RG-3.16, RG-3.17]
roles: [Admin, Bureau, Compta, Commerciale, Usine]
lien_spec_back: ./spec-back.md
# === VALIDATION CLIENT ===
client_validation_1:
statut: validee
date: 2026-05-22
version: V0
valide_par: "Matthieu (CP MALIO)"
client_validation_2:
statut: validee
date: 2026-06-01
version: V0.1
valide_par: "Matthieu (CP MALIO)"
client_validation_3:
statut: a_valider
date: 2026-06-04
version: V0.2
resume: "Module 3 — Répertoire prestataires. Pôle Technique (nouvelle section sidebar). Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Contact / Adresse / Comptabilité (Rapports, Échanges = placeholders 'À venir'). PAS d'onglet Information. Sélecteur de site aussi sur le formulaire principal."
trace_archivee: "uploads/M3-reportoire-prestataires.docx (V0.2) + M3-reportoire-prestataires-V01.pdf (V0.1, obsolète)"
# === LIEN LESSTIME ===
lesstime_taskgroup_id: 29 # M3 — Répertoire prestataires (projet STARSEED #6)
lesstime_project_id: 6
statut_global: en_dev
---
# Module 3 — Répertoire prestataires (V0.2 front)
> **Origine** : spec fonctionnelle `M3-reportoire-prestataires.docx` (V0.2 du 04/06/2026 ; historique V0 22/05 → V0.1 01/06). Restitution Markdown pour intégration au workflow MALIO. Le contenu fonctionnel original n'est pas modifié, **sauf** l'alignement refonte-contact (cf. ci-dessous). Toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M3 réutilise massivement le pattern et les composants posés au [M1 clients](../M1-clients/spec-front.md) et au [M2 fournisseurs](../M2-suppliers/spec-front.md).
> **⚠️ Alignement refonte-contact (décision Matthieu, 11/06/2026)** : le PDF V0.1 portait un **contact principal inline** sur le formulaire principal (Nom du contact / Prénom du contact / Téléphone + bouton + / Email) avec RG-3.01 (Nom OU Prénom) et RG-3.02 (max 2 téléphones). Ce contact inline est **retiré**, exactement comme l'a fait M1/M2 (refonte-contact). Les coordonnées du contact se saisissent **uniquement dans l'onglet Contacts**. **RG-3.01 et RG-3.02 sont donc supprimées du formulaire principal** ; la garantie « au moins un contact nommé » est portée par RG-3.04 + RG-3.12, et le « maximum 2 téléphones » s'applique aux blocs Contact.
> **⚠️ Décision d'architecture (à confirmer) — pôle « Technique »** : le docx place le répertoire prestataires dans un **Module « Technique »**. Confirmé par Matthieu (11/06) : c'est bien un **nouveau pôle Technique**, distinct du Commercial. Côté front cela se traduit par une **nouvelle section sidebar « Technique »** (route `/providers`). Côté back, voir [`spec-back.md § 2.1`](./spec-back.md) (nouveau module `Technique`, entités jumelles du fournisseur, référentiels comptables consommés en relation ORM partagée).
## But
Lister tous les prestataires de l'organisation et accéder rapidement à leurs fiches : consultation, création, modification, archivage. C'est la **porte d'entrée du pôle Technique**.
## Accès
- **Depuis** : menu principal → section **Technique** → entrée « Répertoire prestataires » (route `/providers`).
- **Rôles autorisés** (tableau « Rôles & permissions » du docx) :
| Rôle | Consultation | Création / Modification | Archivage |
|---|---|---|---|
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
| **Bureau** | ✅ Tout | ✅ Tout sauf onglet Comptabilité | ❌ |
| **Compta** | ✅ Tout | ✅ Onglet Comptabilité uniquement | ❌ |
| **Commerciale** | ✅ Tout sauf Comptabilité | ✅ Tout sauf Comptabilité | ❌ |
| **Usine** | ✅ Son site uniquement | — | ❌ |
> **Notes** :
> - RBAC transposée sur `technique.providers.*` (cf. [`spec-back.md § 2.9 / § 5`](./spec-back.md)). Compta édite uniquement l'onglet Comptabilité d'un prestataire existant ; Compta ne peut pas **créer** un prestataire. **L'archivage est réservé à Admin**.
> - **Cloisonnement par site (décision 11/06 — DANS LE PÉRIMÈTRE M3)** : « Tout » vs « son site uniquement » n'est **pas porté par le rôle** mais par l'**utilisateur**. Chaque user a un site courant ; **par défaut il ne voit que les prestataires rattachés à son site**. Les profils qui doivent voir tous les sites (Admin, et par défaut Bureau / Compta / Commerciale) ont la permission `sites.bypass_scope` (Admin l'a automatiquement). **Usine** n'a pas le bypass → cloisonnée à son site. Filtrage **automatique côté back** (cf. [`spec-back.md § 2.13`](./spec-back.md)) — aucun filtre à coder côté front.
## Navigation
Page d'entrée du pôle **Technique** (route `/providers`). Titre : « **Répertoire prestataires** ».
- Affichage principal : un **datatable** listant tous les prestataires **actifs** (les archivés sont masqués par défaut — toggle/filtre dédié).
- **Clic sur une ligne** → écran **Consultation prestataire** (page dédiée).
- **Bouton « + Ajouter »** (haut droite) → écran **Ajouter un prestataire**.
- **Bouton « Filtrer »** (haut droite) → panneau de filtres (cf. ci-dessous).
- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des prestataires **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md).
### Panneau de filtres (bouton « Filtrer »)
Réutilise le pattern M1/M2. Filtres branchés sur les query params de `GET /api/providers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) :
| Filtre | Composant | Query param back |
|---|---|---|
| **Recherche** (nom entreprise / contact / email) | `<MalioInputText>` | `?search=` |
| **Catégorie** | `<MalioSelectCheckbox>` (multi, type PRESTATAIRE) | `?categoryCode=` |
| **Site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | `?siteId=` |
| **Inclure les archivés** | `<MalioCheckbox>` | `?includeArchived=true` |
- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**), qui relance `GET /api/providers`.
- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6).
## Datatable du Répertoire
Composant : `<MalioDataTable>` branché sur `usePaginatedList<Provider>({ url: '/providers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes (alignées M2) :
| Colonne | Source | Tri |
|---|---|---|
| **Nom** | `provider.companyName` | ASC par défaut |
| **Catégories** | `provider.categories[].name` (embarquées en liste — cohérence M1/M2 ; libellé = `name`, pas `label`) | Non |
| **Site** | `provider.sites[].name` (sites du prestataire — cf. note ci-dessous) | Non |
| **Dernière activité** | `provider.updatedAt` (format `JJ-MM-AAAA`) — exposé dans `provider:read` | Oui |
> **Source de la colonne « Site »** : le M3 porte un sélecteur de site **sur le formulaire principal** (RG-3.03) — donc `provider.sites[]` est une relation **directe** du prestataire (≠ M2 où les sites venaient de l'agrégat des adresses). La colonne liste affiche ces sites directs. Voir [`spec-back.md § 2.12`](./spec-back.md).
> **Clic sur une ligne** → écran Consultation. **Pagination** : standard Starseed 10 / 25 / 50 (défaut 10). Tri serveur `companyName ASC` par défaut.
## Écran « Ajouter un prestataire »
Création par **onglets successifs avec validation incrémentale** : pour passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant** ; les champs validés passent en lecture seule + bouton « Valider » désactivé (disabled). Cf. [`spec-back.md § 2.10`](./spec-back.md) (PATCH partiels par groupe de sérialisation).
**Accès** : bouton « + Ajouter » du Répertoire. **Rôles** : Admin, Bureau.
**Barre d'onglets en création (3 onglets)** : `Contact` · `Adresse` · `Comptabilité`. Les onglets `Rapports` et `Échanges` **n'apparaissent PAS dans le flux de création** — ils ne sont présents qu'en Consultation / Modification (placeholders « À venir »).
> **Différence majeure avec M2 : PAS d'onglet « Information ».** Le M3 n'a aucun champ Description / Concurrent / Date création / Salariés / CA / Dirigeant / Résultat / Volume. Le formulaire principal est minimal (3 champs).
> **Règle « placeholder par défaut » (convention MALIO)** : tout onglet ou écran que la spec ne détaille pas explicitement (ici **Rapports** et **Échanges**) est livré en **placeholder « À venir »** (frame vide, navigable, pas de validation ni d'API), à l'identique des autres modules (M1/M2). Aucun champ inventé hors spec.
### Formulaire principal (pré-onglets)
1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/providers`, puis bascule sur l'onglet Contact ; les champs passent en readonly.
| Champ | Type composant | Obligatoire | Règle |
|---|---|---|---|
| **Nom du prestataire (Entreprise)** | `<MalioInputText>` | Oui | RG-3.11 (UPPERCASE serveur) ; RG-3.10 (unicité) |
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | `Category` de **type PRESTATAIRE** via `GET /api/categories?typeCode=PRESTATAIRE` (RG-3.09). Libellé affiché = `category.name`. |
| **Sélecteur de site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | Oui | RG-3.03 — ≥ 1 site. Les 3 cases = les 3 `Site` fixes ; libellés « 86/17/82 » = **préfixe du `postalCode`** (86100 / 17400 / 82400), pas un `Site.code` (qui n'existe pas). La sélection stocke des **IDs de Site** (M2M `provider_site`). |
**Action** : « Valider » (`<MalioButton>`) → POST `/api/providers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Contact ».
### Onglet « Contact »
Saisir un ou plusieurs contacts. Au moins un bloc Contact valide est requis (RG-3.12). **(Refonte-contact : pas de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)**
**Bloc Contact** :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Nom** | `<MalioInputText>` | Conditionnel | RG-3.04 + RG-3.11 (Capitalize) |
| **Prénom** | `<MalioInputText>` | Conditionnel | RG-3.04 + RG-3.11 (Capitalize) |
| **Fonction** | `<MalioInputText>` | Non | — |
| **Téléphone** (x1, +1 possible, **max 2**) | `<MalioInputText>` | Non | RG-3.11 (format) ; max 2 téléphones par contact |
| **Email** | `<MalioInputText>` type email | Non | RG-3.11 (lowercase) |
**RG-3.04 / RG-3.12** : un bloc Contact est valide dès qu'au moins 1 champ est rempli ; au moins 1 bloc Contact valide pour finaliser l'onglet — l'onglet Contact ne peut pas être validé vide.
**Actions** :
- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a pas au moins 1 champ rempli** (RG-3.04).
- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc.
- « Valider » → PATCH `/api/providers/{id}/contacts`.
### Onglet « Adresse »
Saisir une ou plusieurs adresses, rattachées à un ou plusieurs sites (86 / 17 / 82) et à des contacts.
**Bloc Adresse** :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Sélecteur de site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | Oui | RG-3.05 — ≥ 1 site. Stocke des IDs de Site (M2M `provider_address_site`). |
| **Adresse** | `<MalioInputText>` (saisie assistée) | Oui | RG-3.06 — autocomplete BAN |
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
| **Code postal** | `<MalioInputText>` (saisie assistée) | Oui | RG-3.06 — déclenche autocomplete ville (BAN) |
| **Ville** | `<MalioSelect>` (saisie assistée) | Oui | RG-3.06 — alimentée par api-adresse.data.gouv.fr suivant le CP ; si plusieurs villes, choix dans le select |
| **Pays** | `<MalioSelect>` (préremplie « France ») | Oui | — |
| **Catégories** | `<MalioSelectCheckbox>` (multi) | Oui | Catégories de type PRESTATAIRE (RG-3.09) |
| **Contact** | `<MalioSelectCheckbox>` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
> **Différence avec M2** : l'adresse prestataire n'a **PAS** de Type d'adresse (Prospect/Départ/Rendu), **PAS** de Bennes, **PAS** de Prestation de triage. C'est une adresse « simple » (site + adresse postale + catégories + contacts).
**Actions** :
- « + Nouvelle Adresse » : ajoute un bloc identique au premier.
- « Supprimer » (icône) : modal de confirmation puis suppression.
- « Valider » → PATCH `/api/providers/{id}/addresses`.
### Onglet « Comptabilité »
**Accessible aux rôles avec `technique.providers.accounting.view`** (Admin + Compta). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer** cet onglet (`accounting.manage`). Compta ne peut pas créer un prestataire (pas de `manage` global).
**Champs comptables** :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **SIREN** | `<MalioInputText>` (masque 9 chiffres) | Oui | 9 chiffres. **Pas d'unicité** (cf. [`spec-back.md § 2.6`](./spec-back.md)) |
| **Numéro de compte** | `<MalioInputText>` | Oui | — |
| **Mode de TVA** | `<MalioSelect>` | Oui | Liste depuis `/api/tva_modes` (référentiel partagé M1) |
| **N° de TVA** | `<MalioInputText>` | Oui | — |
| **Délai de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_delays` |
| **Type de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_types` |
| **Banque** | `<MalioSelect>` | Conditionnel | RG-3.07 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks` (SG / CIC / CA). |
**Bloc RIB** (0..n, présence obligatoire conditionnée par RG-3.08) :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Libellé** | `<MalioInputText>` | Oui (si LCR) | RG-3.08 |
| **BIC** | `<MalioInputText>` | Oui (si LCR) | RG-3.08 |
| **IBAN** | `<MalioInputText>` | Oui (si LCR) | RG-3.08 |
**Actions** :
- « + RIB » : ajoute un bloc.
- « Supprimer » (icône) : modal de confirmation.
- « Valider » → PATCH `/api/providers/{id}` (groupe `provider:write:accounting`) + sous-ressource RIBs.
## Écran « Consultation prestataire »
Tous les champs en **lecture seule**. La page s'ouvre par défaut sur l'onglet **Contacts**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans `+` pour ajouter des blocs.
- **Flèche retour** (gauche) → revient au Répertoire.
- **Bouton « Modifier »** (droite, visible si `technique.providers.manage`) → écran Modification.
- **Bouton « Archiver »** (droite, visible **uniquement Admin** via `technique.providers.archive`) → modal de confirmation, puis PATCH `/api/providers/{id}` `{ "isArchived": true }`.
> Un prestataire archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé.
### Onglets affichés en consultation
`Contacts` · `Adresse` · `Rapports` · `Échanges` · `Comptabilité`. Navigation **libre** entre onglets (pas de séquence forcée). `Rapports` et `Échanges` = placeholders « À venir ». `Comptabilité` selon permission.
- **Onglet Contacts** : un bloc par contact, 5 champs en lecture seule (Nom / Prénom / Fonction / Téléphone / Email).
- **Onglet Adresse** : un bloc par adresse, en lecture seule (Sélecteur de site / Adresse / Adresse complémentaire / Code postal / Ville / Pays / Catégorie / Contact).
- **Onglet Comptabilité** : bloc principal (champs comptables) + un bloc par RIB. Le champ **Banque** n'apparaît que si Type de règlement = Virement (RG-3.07).
## Écran « Modification prestataire »
Comportement identique à l'écran Ajouter (mêmes formulaires, mêmes RG-3.03 → RG-3.08) sauf :
- **Pas de formulaire principal** réaffiché (champs principaux édités via l'onglet correspondant / pré-remplis).
- Les champs sont **pré-remplis** avec les valeurs actuelles du prestataire.
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
- Les onglets pour lesquels l'utilisateur n'a **pas** la permission `manage` (ou `accounting.manage`) restent en **lecture seule** (pas de bouton Valider, pas d'icône suppression).
- **Accès** : Admin, Bureau (Compta pour l'onglet Comptabilité uniquement).
## Composants UI à utiliser (`@malio/layer-ui`)
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
- **Input texte** : `<MalioInputText>`
- **Select simple** : `<MalioSelect>` (Pays, Ville, référentiels comptables)
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (Catégorie, Sites, Contacts rattachés)
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
- **Toasts** : standards via `useApi()`
- **Validation par champ** : `useFormErrors` (mapping 422 inline — règle frontend obligatoire)
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
- Modal de confirmation : `<MalioModal>` ou wrapper partagé dans `frontend/shared/` (réutiliser celui du M1/M2).
## Composables & appels API
- `usePaginatedList<Provider>({ url: '/providers' })` — liste paginée (obligatoire). La liste consomme `categories[]` (libellé = `name`) et `sites[]` (libellé = `name`, pas de `code`) **embarqués** + `updatedAt` (cf. [`spec-back.md § 2.12 / § 4.0`](./spec-back.md)).
- `useProvider(id)` — charge le détail via `GET /api/providers/{id}`, qui **embarque** `contacts`, `addresses` (avec `sites` / `categories` / `contacts` imbriqués) et, si permission, `ribs` + scalaires compta. Écrans Consultation et Modification peuplés depuis cette seule réponse (RETEX M1 §2 : embed borné, pas de N+1). **DoD avant intégration** : vérifier que le JSON réel contient ces blocs (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)).
- `useProviderForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useSupplierForm()`.
- `useAddressAutocomplete()`**réutilisé du M1/M2** (BAN), pas de réécriture.
- `usePermissions()` — masque l'onglet Comptabilité et le bouton Archiver.
- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4).
- Filter `formatPhoneFR()`**réutilisé** pour l'affichage `XX XX XX XX XX`.
## Règles de formatage et normalisation
Le serveur normalise systématiquement (RG-3.11 — cf. [`spec-back.md`](./spec-back.md)) :
| Champ | Normalisation serveur | Affichage front |
|---|---|---|
| Nom prestataire (`companyName`) | UPPERCASE intégral | UPPERCASE |
| Nom + Prénom contact | Capitalize | identique |
| Téléphones (blocs `ProviderContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` (filter Vue) |
| Email | lowercase intégral | identique |
> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur normalisée que l'UI affiche.
## API adresse postale
Code postal + Ville + Adresse branchés sur **api-adresse.data.gouv.fr** (BAN) via le composable `useAddressAutocomplete()` **déjà créé au M1/M2** (réutilisé tel quel) :
- À la saisie du CP (5 chiffres) : `GET https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville (RG-3.06 : si plusieurs villes, choix dans le select).
- À la saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions.
- Cas dégradé (timeout / offline) : Ville en `<MalioInputText>` libre + toast d'avertissement.
## Différences notables avec le M2 (fournisseurs)
| Zone | M2 fournisseurs | M3 prestataires |
|---|---|---|
| Onglet Information | 8 champs (Description … Volume) | **Absent** (aucun champ Information) |
| Sélecteur de site sur formulaire principal | Non (sites uniquement via adresses) | **Oui** (RG-3.03 — relation directe `provider.sites`) |
| Type d'adresse | Radio Prospect / Départ / Rendu (RG-2.09) | **Absent** |
| Bennes / Prestation de triage (adresse) | Présents | **Absents** |
| Onglet Transport | Placeholder | **Absent** |
| Onglet Statistiques | Placeholder | **Absent** |
| Onglets « À venir » | Transport / Stats / Rapports / Échanges | **Rapports / Échanges** uniquement |
| Catégories | type `FOURNISSEUR` | **nouveau type `PRESTATAIRE`** |
| Pôle / module | Commercial | **Technique** (nouvelle section sidebar + module back) |
| Cloisonnement par site | aucun | **Visibilité par site, pilotée par l'utilisateur** (bypass via `sites.bypass_scope`) — § 2.13 |
## Points résolus côté back
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|---|---|---|
| 1 | Catégorie multi-select | M2M `provider_category`, `Category` de type **PRESTATAIRE** (RG-3.09) |
| 2 | Site sur le formulaire principal | M2M `provider_site` (≥ 1 — RG-3.03), distinct de `provider_address_site` (RG-3.05) |
| 3 | Onglet Comptabilité : qui édite ? | Admin + Compta (`accounting.manage`) ; Bureau/Commerciale ne le voient pas |
| 4 | Workflow par onglet | Sauvegarde incrémentale (POST principal + PATCH partiels) — pas d'état « draft » |
| 5 | Onglets « À venir » | Placeholder minimal « À venir » (Rapports / Échanges) |
| 6 | Archive vs delete | Flag `is_archived` séparé de `deleted_at` ; archivage Admin seul ; soft delete = HP |
| 7 | Unicité métier | Nom de prestataire uniquement (à valider — § 2.6). SIREN/email non uniques |
| 8 | Référentiels comptables | Réutilisés M1/M2 (zéro duplication) ; relation ORM partagée |
| 9 | API code postal | BAN via `useAddressAutocomplete()` du M1/M2 (RG-3.06) |
| 10 | Format export | XLSX uniquement (CSV = HP) |
| 11 | Cloisonnement par site (Usine « son site ») | Filtre back automatique par `currentSite` + bypass `sites.bypass_scope` (§ 2.13 / RG-3.17) |
---
## 📦 Tickets Lesstime
**TaskGroup Lesstime** : **#29 — M3 — Répertoire prestataires** (projet `ERP / Starseed`, projectId=6) — créé le 11/06/2026, 16 tickets `ERP-131``ERP-146`, statut « Prêt à dev », assignés à **Tristan**.
| # | Ticket | Réf | Tag |
|---|---|---|---|
| 1.1 | Créer module Technique + taxonomie PRESTATAIRE | ERP-131 | Backend |
| 1.2 | Migrer le schéma BDD M3 (provider + sous-collections) | ERP-132 | Backend |
| 1.3 | Créer entités + repositories Provider* | ERP-133 | Backend |
| 1.4 | ProviderProvider + ProviderProcessor + cloisonnement site | ERP-134 | Backend |
| 1.5 | Sous-ressources Contacts / Adresses / RIBs | ERP-135 | Backend |
| 1.6 | Valider les RG métier server-side (RG-3.03→3.09) | ERP-136 | Backend |
| 1.7 | Export XLSX des prestataires | ERP-137 | Backend |
| 1.8 | RBAC technique.providers.* (3 sources) | ERP-138 | Backend |
| 1.9 | PHPUnit RG-3.x + capture contrat JSON | ERP-139 | Backend |
| 1.10 | Page Répertoire (/providers) | ERP-140 | Frontend |
| 1.11 | Page Ajouter (/providers/new) + formulaire principal | ERP-141 | Frontend |
| 1.12 | Onglet Contact | ERP-142 | Frontend |
| 1.13 | Onglet Adresse (autocomplete BAN) | ERP-143 | Frontend |
| 1.14 | Onglet Comptabilité + RIB | ERP-144 | Frontend |
| 1.15 | Pages Consultation + Modification | ERP-145 | Frontend |
| 1.16 | i18n + sidebar Technique + libellés audit | ERP-146 | Frontend |
> Détail back complet → voir [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper).
-80
View File
@@ -1,80 +0,0 @@
# RETEX M1 (Clients) → à appliquer pour M2 (Fournisseurs)
> But : éviter de reproduire en M2 les erreurs de **contrat de sérialisation** qui ont bloqué M1.
> ~80 % des frictions M1 venaient du contrat API (sérialisation / groupes / sous-ressources), **pas** du métier.
> À lire AVANT de rédiger `spec-back.md` et `spec-front.md` du M2, et à garder ouvert pendant la rédaction.
---
## 0. TL;DR (les 3 erreurs à ne jamais refaire)
1. **Affirmer qu'un champ est « embarqué » sans vérifier les 3 maillons de sérialisation.** En M1 : `Category.code` annoncé dans `client:read`, détail annoncé embarquant contacts/adresses/ribs → **faux dans le code**. Résultat : colonnes liste vides, onglets détail impossibles à peupler.
2. **Livrer des sous-ressources en POST-only** (pas de `GetCollection`, pas d'embed) → le front ne peut pas lister les enfants de l'agrégat.
3. **Écrire la spec/les tickets sur une intention, pas sur le contrat réel.** Le docblock `Client` décrivait un embed jamais implémenté.
---
## 1. Contrat de sérialisation : les 3 maillons obligatoires
Pour **chaque champ affiché** (liste OU détail), la spec back doit prouver les trois maillons. Si un seul manque → le champ sort en quasi-IRI (`@id`/`@type` seulement) ou pas du tout.
| Maillon | Question | Exemple M1 raté |
|---|---|---|
| (a) Groupe sur la **propriété** | `#[Groups([...])]` contient-il un read-group ? | `Supplier::$addresses` sans groupe → jamais sérialisé |
| (b) Groupe dans le **`normalizationContext` de l'opération** | l'opération (`GetCollection`/`Get`) liste-t-elle ce groupe ? | `GetCollection` en `['client:read','default:read']` |
| (c) Read-group de l'**entité imbriquée** dans le contexte parent | pour embarquer les champs d'une relation (catégorie, site…), le contexte parent inclut-il `category:read` / `site:read` ? | `Category.code``category:read`, absent du contexte client → pas de `code` |
**Règle de rédaction** : dans `spec-back.md`, faire un tableau « champ → groupe propriété → groupe(s) à ajouter au contexte de chaque opération » pour la liste ET le détail. Inclure explicitement les **relations imbriquées** (ex. catégories d'une adresse, sites d'une adresse).
## 2. Collections enfant d'un agrégat : décider embed vs GetCollection, et câbler en ENTIER
Décision à acter dès la spec back pour chaque sous-collection (contacts, adresses, RIB, lignes…) :
- **Embed dans le détail (recommandé pour un agrégat DDD)** : poser `#[Groups(['<root>:item:read'])]` sur la propriété + ajouter au `normalizationContext` du `Get` racine les read-groups des entités enfant **et** de leurs relations imbriquées. 1 requête, cohérent avec un composable `useX(id)`. Réservé aux ensembles **bornés** (ne viole pas la règle n°13 : elle vise les collections exposées, pas un embed borné d'item).
- **GetCollection sous-ressource** : `/<root>/{id}/children` paginé. À réserver aux collections potentiellement volumineuses. Si choisi, **créer l'opération** (pas seulement POST).
❌ Anti-pattern M1 : sous-ressources avec `POST` + `Get` unitaire seulement → **aucun moyen de lister** (ids non découvrables). Interdit.
## 3. Vérifier le contrat sur l'API RÉELLE avant d'écrire les tickets front
Le blocage M1 (codes/sites/sous-collections) aurait été vu en 5 min. À mettre dans la **definition of done de la spec back** :
> Créer un enregistrement de test, appeler `GET /api/<resource>` (liste) ET `GET /api/<resource>/{id}` (détail), **coller la réponse JSON réelle** dans la spec. Toute donnée affichée par le front doit apparaître dans ce JSON collé.
## 4. La spec décrit le RÉEL, pas l'intention
- Bannir les « devrait être embarqué », « est exposé » non vérifiés. Décrire ce qui existe (ou ce qui sera livré dans le ticket, en le marquant clairement « à livrer »).
- Si un docblock/commentaire existant contredit le code, le **corriger**, pas le recopier.
## 5. Réutiliser les acquis M1 (ne pas réinventer)
- **Taxonomie ERP-78** : si M2 catégorise les fournisseurs, repartir du modèle **type unique + `code` stable** (slug MAJUSCULE auto-généré, NOT NULL, figé, **lecture seule** `category:read`), filtrage métier via `?categoryCode=`. Réutiliser le contrat partagé `CategoryInterface` (pas d'import inter-module).
- **Front** : `usePaginatedList` (listes), composants `Malio*`, `useApi()`, `formatPhoneFR`, blocs réutilisables (Contact/Adresse), pattern de blocs dynamiques + modal de confirmation.
- **Archive** : flag `is_archived` **distinct** de `deleted_at` (soft delete). Restauration → gérer le 409 homonyme.
- **Normalisation = serveur** (UPPERCASE nom société, Capitalize noms, lowercase email, téléphone en chiffres). Le front envoie la saisie, réaffiche la valeur normalisée renvoyée. À documenter dans la spec.
- **Gating fin + mode strict PATCH** : PATCH par groupe de sérialisation ; tout champ hors-permission dans le payload = **403 sur l'intégralité** (pas de filtrage silencieux). Spécifier la matrice rôle × onglet.
## 6. Règles ABSOLUES transverses à rappeler dans la spec M2
- **Pagination obligatoire** (règle n°13) sur toute `GetCollection` ; échappatoire `?pagination=false` réservée aux selects de référentiels bornés.
- **`COMMENT ON COLUMN`** (règle n°12) sur chaque colonne créée/modifiée (sinon `make test` casse). Helper standard pour les colonnes Timestampable/Blamable.
- **Timestampable + Blamable** sur toute nouvelle entité métier (4 colonnes + trait) ; garde-fou archi.
- **`#[Auditable]`** sur les entités métier ; **`#[AuditIgnore]`** sur les champs sensibles (équivalents BIC/IBAN/secret).
- **`declare(strict_types=1);`** partout ; commentaires FR, code EN.
- **Routes front à plat** (pas de préfixe module), état tableau **jamais** dans l'URL.
- **3 miroirs RBAC** à toucher ensemble : `config/sidebar.php`, `frontend/tests/e2e/_fixtures/personas.ts`, `SeedE2ECommand.php`.
- **Communication inter-module** uniquement via `Shared/Domain/Contract/` ou domain events — jamais d'import direct.
## 7. Fixtures & seed dès le départ
M1 a subi un aller-retour (ERP-68) faute de fixtures alignées. Pour M2 : prévoir dès la spec un seed de fournisseurs démo **couvrant tous les cas des règles métier** (relations, catégories codées, archivés, cas comptables) + comptes de rôles démo, pour vérifier le gating et le golden path sans bricolage.
## 8. Mini-checklist de relecture de la spec M2 (avant de la déclarer prête)
- [ ] Chaque champ affiché (liste + détail) a ses 3 maillons de sérialisation documentés (propriété, contexte opération, relations imbriquées).
- [ ] Chaque sous-collection a une décision **embed vs GetCollection** explicite et **complètement câblée** (pas de POST-only).
- [ ] Réponses JSON réelles (liste + détail) collées dans la spec back.
- [ ] Matrice RBAC rôle × écran × onglet + mode strict PATCH spécifiés.
- [ ] Pagination, COMMENT ON COLUMN, Timestampable/Blamable, Audit, routes à plat : rappelés.
- [ ] Réutilisations M1 identifiées (taxonomie code, usePaginatedList, blocs, archive, normalisation).
- [ ] Seed/fixtures démo planifiés.
+5 -10
View File
@@ -30,10 +30,6 @@
"clients": "Répertoire clients",
"suppliers": "Répertoire fournisseurs"
},
"technique": {
"section": "Technique",
"providers": "Répertoire prestataires"
},
"core": {
"roles": "Gestion des rôles",
"users": "Utilisateurs",
@@ -390,7 +386,10 @@
},
"title": "Erreur",
"generic": "Une erreur est survenue.",
"unknown": "Erreur inconnue."
"unknown": "Erreur inconnue.",
"validation": {
"invalidDate": "Date invalide"
}
},
"sites": {
"selector": {
@@ -417,11 +416,7 @@
"commercial_supplier": "Fournisseur",
"commercial_supplieraddress": "Adresse fournisseur",
"commercial_suppliercontact": "Contact fournisseur",
"commercial_supplierrib": "RIB fournisseur",
"technique_provider": "Prestataire",
"technique_provideraddress": "Adresse prestataire",
"technique_providercontact": "Contact prestataire",
"technique_providerrib": "RIB prestataire"
"commercial_supplierrib": "RIB fournisseur"
},
"empty": "Aucune activité enregistrée",
"no_results": "Aucun résultat pour ces filtres",
@@ -187,7 +187,7 @@ import {
addressTypeFromFlags,
isBillingEmailRequired,
type AddressType,
} from '~/modules/commercial/utils/clientFormRules'
} from '~/modules/commercial/utils/forms/clientFormRules'
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
@@ -1,5 +1,5 @@
import { ref } from 'vue'
import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation'
import type { ClientDetail } from '~/modules/commercial/utils/forms/clientConsultation'
/**
* Chargement et actions d'archivage d'un client unique (ecran « Consultation
@@ -1,5 +1,5 @@
import { ref } from 'vue'
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
/**
* Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation
@@ -116,6 +116,7 @@
:readonly="businessReadonly"
:editable="true"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
<MalioInputText
v-model="information.employeesCount"
@@ -401,7 +402,7 @@ import {
mapAddressToDraft,
mapRibToDraft,
type ClientDetail,
} from '~/modules/commercial/utils/clientConsultation'
} from '~/modules/commercial/utils/forms/clientConsultation'
import {
buildAccountingPayload,
buildAddressPayload,
@@ -417,7 +418,7 @@ import {
type ClientEditAbilities,
type InformationFormDraft,
type MainFormDraft,
} from '~/modules/commercial/utils/clientEdit'
} from '~/modules/commercial/utils/forms/clientEdit'
import {
buildClientFormTabKeys,
isAddressValid,
@@ -429,7 +430,7 @@ import {
isRibComplete,
isRibRequiredForPaymentType,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/clientFormRules'
} from '~/modules/commercial/utils/forms/clientFormRules'
import {
emptyAddress,
emptyContact,
@@ -280,7 +280,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient'
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/clientFormRules'
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab'
import {
canEditClient,
@@ -297,7 +297,7 @@ import {
showRestoreAction,
type ClientDetail,
type SelectOption,
} from '~/modules/commercial/utils/clientConsultation'
} from '~/modules/commercial/utils/forms/clientConsultation'
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
@@ -111,6 +111,7 @@
:readonly="isValidated('information')"
:editable="true"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
<MalioInputText
v-model="information.employeesCount"
@@ -401,12 +402,12 @@ import {
isRibRequiredForPaymentType,
lastFillableTabKey,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/clientFormRules'
} from '~/modules/commercial/utils/forms/clientFormRules'
import {
buildAddressPayload,
buildMainPayload,
buildRibPayload,
} from '~/modules/commercial/utils/clientEdit'
} from '~/modules/commercial/utils/forms/clientEdit'
import {
emptyAddress,
emptyContact,
@@ -651,6 +652,8 @@ const information = reactive({
description: null as string | null,
competitors: null as string | null,
foundedAt: null as string | null,
// Saisie brute invalide remontee par MalioDate (cf. foundedAtRaw, MUI-44).
foundedAtRaw: '',
employeesCount: null as string | null,
revenueAmount: null as string | null,
profitAmount: null as string | null,
@@ -666,7 +669,8 @@ async function submitInformation(): Promise<void> {
await api.patch(`/clients/${clientId.value}`, {
description: information.description || null,
competitors: information.competitors || null,
foundedAt: information.foundedAt || null,
// Saisie invalide prioritaire -> 422 back sur foundedAt (cf. foundedAtRaw).
foundedAt: information.foundedAtRaw || information.foundedAt || null,
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
revenueAmount: information.revenueAmount || null,
profitAmount: information.profitAmount || null,
@@ -77,6 +77,7 @@
:readonly="businessReadonly"
:editable="true"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
<MalioInputText
v-model="information.employeesCount"
@@ -370,7 +371,7 @@ import {
mapAddressToDraft,
mapRibToDraft,
type SupplierDetail,
} from '~/modules/commercial/utils/supplierConsultation'
} from '~/modules/commercial/utils/forms/supplierConsultation'
import {
buildAccountingPayload,
buildAddressPayload,
@@ -386,7 +387,7 @@ import {
type InformationFormDraft,
type MainFormDraft,
type SupplierEditAbilities,
} from '~/modules/commercial/utils/supplierEdit'
} from '~/modules/commercial/utils/forms/supplierEdit'
import {
buildSupplierFormTabKeys,
isAddressValid,
@@ -396,7 +397,7 @@ import {
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
} from '~/modules/commercial/utils/supplierFormRules'
} from '~/modules/commercial/utils/forms/supplierFormRules'
import {
emptyAddress,
emptyContact,
@@ -263,7 +263,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/supplierFormRules'
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab'
import {
canEditSupplier,
@@ -280,7 +280,7 @@ import {
showRestoreAction,
type SelectOption,
type SupplierDetail,
} from '~/modules/commercial/utils/supplierConsultation'
} from '~/modules/commercial/utils/forms/supplierConsultation'
import { emptyContact } from '~/modules/commercial/types/supplierForm'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
@@ -71,6 +71,7 @@
:readonly="isValidated('information')"
:editable="true"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
<MalioInputText
v-model="information.employeesCount"
@@ -361,7 +362,7 @@ import {
isRibComplete,
isRibRequiredForPaymentType,
lastFillableTabKey,
} from '~/modules/commercial/utils/supplierFormRules'
} from '~/modules/commercial/utils/forms/supplierFormRules'
import {
buildAccountingPayload,
buildAddressPayload,
@@ -369,7 +370,7 @@ import {
buildInformationPayload,
buildMainPayload,
buildRibPayload,
} from '~/modules/commercial/utils/supplierEdit'
} from '~/modules/commercial/utils/forms/supplierEdit'
import {
emptyAddress,
emptyContact,
@@ -549,6 +550,8 @@ const information = reactive({
description: null as string | null,
competitors: null as string | null,
foundedAt: null as string | null,
// Saisie brute invalide remontee par MalioDate (cf. foundedAtRaw, MUI-44).
foundedAtRaw: '',
employeesCount: null as string | null,
revenueAmount: null as string | null,
profitAmount: null as string | null,
@@ -36,6 +36,7 @@ function informationDraft(overrides: Partial<InformationFormDraft> = {}): Inform
description: 'desc',
competitors: 'concurrents',
foundedAt: '2010-05-01',
foundedAtRaw: '',
employeesCount: '42',
revenueAmount: '1000000',
profitAmount: '50000',
@@ -140,6 +141,16 @@ describe('buildInformationPayload — scoping strict groupe client:write:informa
expect(payload.description).toBeNull()
expect(payload.directorName).toBeNull()
})
it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => {
// Saisie malformee : on transmet le texte brut tel quel pour declencher la
// 422 back sur foundedAt (validation autoritaire du format, MUI-44).
expect(buildInformationPayload(informationDraft({ foundedAt: null, foundedAtRaw: '32/13/2026' })).foundedAt)
.toBe('32/13/2026')
// Saisie valide : foundedAtRaw vide -> on envoie la date ISO.
expect(buildInformationPayload(informationDraft({ foundedAt: '2010-05-01', foundedAtRaw: '' })).foundedAt)
.toBe('2010-05-01')
})
})
describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => {
@@ -11,7 +11,7 @@ import {
mapMainDraft,
resolveTabEditability,
} from '../supplierEdit'
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm'
describe('buildMainPayload (groupe supplier:write:main)', () => {
@@ -37,7 +37,7 @@ describe('buildMainPayload (groupe supplier:write:main)', () => {
describe('buildInformationPayload (groupe supplier:write:information)', () => {
const base = {
description: null, competitors: null, foundedAt: null, employeesCount: null,
description: null, competitors: null, foundedAt: null, foundedAtRaw: '', employeesCount: null,
revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null,
}
@@ -48,6 +48,15 @@ describe('buildInformationPayload (groupe supplier:write:information)', () => {
})
expect(buildInformationPayload(base)).toMatchObject({ employeesCount: null, volumeForecast: null })
})
it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => {
// Saisie malformee transmise telle quelle pour declencher la 422 back (MUI-44).
expect(buildInformationPayload({ ...base, foundedAt: null, foundedAtRaw: '32/13/2026' }).foundedAt)
.toBe('32/13/2026')
// Saisie valide : foundedAtRaw vide -> on envoie la date ISO.
expect(buildInformationPayload({ ...base, foundedAt: '2008-04-01', foundedAtRaw: '' }).foundedAt)
.toBe('2008-04-01')
})
})
describe('buildContactPayload (sous-ressource supplier_contact)', () => {
@@ -20,14 +20,14 @@ import {
iriOf,
relationOf,
type ClientDetail,
} from '~/modules/commercial/utils/clientConsultation'
} from '~/modules/commercial/utils/forms/clientConsultation'
import {
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
blankEmptyRequired,
MAIN_REQUIRED_NON_NULLABLE_KEYS,
omitEmptyRequired,
RIB_REQUIRED_NON_NULLABLE_KEYS,
} from '~/modules/commercial/utils/clientFormRules'
} from '~/modules/commercial/utils/forms/clientFormRules'
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
/**
@@ -53,6 +53,13 @@ export interface InformationFormDraft {
competitors: string | null
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
foundedAt: string | null
/**
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
*/
foundedAtRaw: string
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
employeesCount: string | null
revenueAmount: string | null
@@ -118,6 +125,8 @@ export function mapInformationDraft(client: ClientDetail): InformationFormDraft
competitors: client.competitors ?? null,
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null,
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
foundedAtRaw: '',
employeesCount: client.employeesCount != null ? String(client.employeesCount) : null,
revenueAmount: client.revenueAmount ?? null,
profitAmount: client.profitAmount ?? null,
@@ -191,7 +200,9 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
return {
description: information.description || null,
competitors: information.competitors || null,
foundedAt: information.foundedAt || null,
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
foundedAt: information.foundedAtRaw || information.foundedAt || null,
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
revenueAmount: information.revenueAmount || null,
profitAmount: information.profitAmount || null,
@@ -17,8 +17,8 @@ import {
MAIN_REQUIRED_NON_NULLABLE_KEYS,
omitEmptyRequired,
RIB_REQUIRED_NON_NULLABLE_KEYS,
} from '~/modules/commercial/utils/supplierFormRules'
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
} from '~/modules/commercial/utils/forms/supplierFormRules'
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
import type {
SupplierAddressFormDraft,
SupplierContactFormDraft,
@@ -38,6 +38,13 @@ export interface InformationFormDraft {
competitors: string | null
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
foundedAt: string | null
/**
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
*/
foundedAtRaw: string
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
employeesCount: string | null
revenueAmount: string | null
@@ -95,6 +102,8 @@ export function mapInformationDraft(supplier: SupplierDetail): InformationFormDr
competitors: supplier.competitors ?? null,
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null,
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
foundedAtRaw: '',
employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null,
revenueAmount: supplier.revenueAmount ?? null,
profitAmount: supplier.profitAmount ?? null,
@@ -177,7 +186,9 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
return {
description: information.description || null,
competitors: information.competitors || null,
foundedAt: information.foundedAt || null,
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
foundedAt: information.foundedAtRaw || information.foundedAt || null,
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
revenueAmount: information.revenueAmount || null,
profitAmount: information.profitAmount || null,
+4 -4
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend",
"hasInstallScript": true,
"dependencies": {
"@malio/layer-ui": "^1.7.8",
"@malio/layer-ui": "^1.7.10",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -1866,9 +1866,9 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
"version": "1.7.8",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.8/layer-ui-1.7.8.tgz",
"integrity": "sha512-gUMAZzBsPCfQUF3OQSjN/OFzjONvQZYfwqH0u5VUbxaqwBdX1hUGtjD4ym6RvZkyNsKulrxkncFZYTWCS+IdGA==",
"version": "1.7.10",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.10/layer-ui-1.7.10.tgz",
"integrity": "sha512-ZWYaKvl+VpGAqeTE+4xdyKOmuRd4zwjlUYVppeIBZwGeNAK16kZnrztR+4eQmnzUqPZVybBhEBdKP9weqWHSUg==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@malio/layer-ui": "^1.7.8",
"@malio/layer-ui": "^1.7.10",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -41,6 +41,22 @@ describe('useFormErrors', () => {
expect(hasErrors.value).toBe(true)
})
it('setServerErrors surcharge un message technique (erreur de type) par la cle i18n', () => {
const { errors, setServerErrors } = useFormErrors()
const mapped = setServerErrors({
violations: [
// Code Symfony Type::INVALID_TYPE_ERROR (date non parsable) : surcharge.
{ propertyPath: 'foundedAt', message: 'Cette valeur doit être de type DateTimeImmutable|null.', code: 'ba785a8c-82cb-4283-967c-3cf342181b40' },
// Violation metier classique : message back conserve.
{ propertyPath: 'companyName', message: 'Obligatoire.', code: 'c1051bb4-d103-4f74-8988-acbcafc7fdc3' },
],
})
expect(mapped).toBe(true)
// Stub i18n -> renvoie la cle telle quelle.
expect(errors.foundedAt).toBe('errors.validation.invalidDate')
expect(errors.companyName).toBe('Obligatoire.')
})
it('setServerErrors retourne false et ne touche rien sans violation', () => {
const { errors, setServerErrors } = useFormErrors()
expect(setServerErrors({})).toBe(false)
+10 -7
View File
@@ -17,7 +17,7 @@
* appel par ligne), utiliser directement `mapViolationsToRecord` par ligne.
*/
import { computed, reactive } from 'vue'
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
import { extractApiErrorMessage, extractApiViolations, resolveViolationMessage } from '~/shared/utils/api'
/**
* Erreur HTTP capturee par ofetch. On n'expose que les champs lus ici (status
@@ -69,13 +69,16 @@ export function useFormErrors() {
* violation exploitable).
*/
function setServerErrors(data: unknown): boolean {
const mapped = mapViolationsToRecord(data)
const keys = Object.keys(mapped)
if (keys.length === 0) return false
for (const key of keys) {
errors[key] = mapped[key]
const violations = extractApiViolations(data)
let mapped = false
for (const v of violations) {
if (!v.propertyPath) continue
// Message back tel quel, sauf code surcharge par une cle i18n (ex.
// erreur de type sur une date non parsable -> « Date invalide »).
errors[v.propertyPath] = resolveViolationMessage(v, t)
mapped = true
}
return true
return mapped
}
/**
+28 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { mapViolationsToRecord } from '../api'
import { mapViolationsToRecord, resolveViolationMessage } from '../api'
/**
* Tests de `mapViolationsToRecord` fondation du mapping erreurchamp des
@@ -56,3 +56,30 @@ describe('mapViolationsToRecord', () => {
expect(mapViolationsToRecord(data)).toEqual({ name: 'Second message.' })
})
})
/**
* Tests de `resolveViolationMessage` surcharge i18n d'un message back par code
* de violation. Le back peut renvoyer un message technique (erreur de type sur
* une date non parsable) : on le remplace via le `code` Symfony (stable) par une
* cle i18n, sans toucher au back. Le `t` ici renvoie la cle telle quelle.
*/
describe('resolveViolationMessage', () => {
const t = (key: string) => key
// Code Symfony Constraints\Type::INVALID_TYPE_ERROR (fige).
const TYPE_ERROR = 'ba785a8c-82cb-4283-967c-3cf342181b40'
it('surcharge le message technique d\'une erreur de type par la cle i18n', () => {
const v = { propertyPath: 'foundedAt', message: 'Cette valeur doit être de type DateTimeImmutable|null.', code: TYPE_ERROR }
expect(resolveViolationMessage(v, t)).toBe('errors.validation.invalidDate')
})
it('renvoie le message back tel quel quand le code n\'est pas surcharge', () => {
const v = { propertyPath: 'companyName', message: 'Le nom est obligatoire.', code: 'c1051bb4-d103-4f74-8988-acbcafc7fdc3' }
expect(resolveViolationMessage(v, t)).toBe('Le nom est obligatoire.')
})
it('renvoie le message back tel quel quand il n\'y a pas de code', () => {
const v = { propertyPath: 'siren', message: 'SIREN deja utilise.', code: '' }
expect(resolveViolationMessage(v, t)).toBe('SIREN deja utilise.')
})
})
+45 -1
View File
@@ -34,11 +34,15 @@ export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
/**
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
* pointe le champ concerne, `message` est le libelle a afficher.
* pointe le champ concerne, `message` est le libelle a afficher, `code` est le
* code de contrainte Symfony (UUID stable, independant de la langue) il sert
* a surcharger un message back technique par une cle i18n (cf.
* `VIOLATION_MESSAGE_I18N` / `resolveViolationMessage`).
*/
export interface ApiViolation {
propertyPath: string
message: string
code: string
}
/**
@@ -61,6 +65,7 @@ export function extractApiViolations(data: unknown): ApiViolation[] {
out.push({
propertyPath: String(obj.propertyPath ?? ''),
message: String(obj.message ?? ''),
code: String(obj.code ?? ''),
})
}
return out
@@ -85,6 +90,45 @@ export function mapViolationsToRecord(data: unknown): Record<string, string> {
return out
}
/**
* Surcharge i18n d'un message back par CODE de violation.
*
* La plupart des contraintes back portent deja un message FR explicite (ex.
* `#[Assert\NotBlank(message: '...')]`) : on l'affiche tel quel. Mais certaines
* 422 portent un message TECHNIQUE non montrable a l'utilisateur typiquement
* l'erreur de TYPE renvoyee par API Platform quand le back ne peut pas
* denormaliser la valeur (date non parsable envoyee sur un champ
* `DateTimeImmutable` : « Cette valeur doit être de type DateTimeImmutable|null. »,
* voire en anglais selon la negociation de langue).
*
* Plutot que de traduire/maquiller cote back, on surcharge ces messages cote
* front via leur `code` de violation. Ce code est un UUID Symfony FIGE (contrat
* de compatibilite : il ne change pas entre versions), donc bien plus robuste
* qu'un match sur le texte du message (qui depend de la langue). La table
* associe un code -> une cle i18n ; `resolveViolationMessage` l'applique.
*
* Limite a connaitre : le code de type-error est GENERIQUE (toute valeur de
* mauvais type). Dans nos formulaires, seul un champ date saisi en texte libre
* (MalioDate, qui forwarde la saisie brute invalide) le declenche, d'ou le
* libelle « Date invalide ». Si un autre champ typé en saisie libre apparait,
* affiner la resolution via `propertyPath` plutot que par code seul.
*/
export const VIOLATION_MESSAGE_I18N: Record<string, string> = {
// Symfony `Constraints\Type::INVALID_TYPE_ERROR` — valeur de mauvais type.
'ba785a8c-82cb-4283-967c-3cf342181b40': 'errors.validation.invalidDate',
}
/**
* Resout le message a afficher pour une violation : si son `code` est surcharge
* par `VIOLATION_MESSAGE_I18N`, renvoie la traduction de la cle associee ;
* sinon, le message back tel quel (cas nominal). `t` est passe par l'appelant
* (les utils sont purs, sans acces a useI18n).
*/
export function resolveViolationMessage(v: ApiViolation, t: (key: string) => string): string {
const i18nKey = VIOLATION_MESSAGE_I18N[v.code]
return i18nKey ? t(i18nKey) : v.message
}
/**
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
-11
View File
@@ -84,17 +84,6 @@ export const personas: Record<PersonaKey, Persona> = {
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
'commercial.suppliers.archive',
// Technique — Repertoire prestataires (M3, ERP-138). Meme logique que
// clients/fournisseurs : mappe sur le persona "tout", pas de nouveau
// persona (regle ABSOLUE n°7). user-full porte deja sites.bypass_scope,
// donc il voit les prestataires de tous les sites (M3 § 2.13).
// technique.providers.view n'ajoute pas de lien dans la section
// Administration, donc expectedAdminLinks reste inchange.
'technique.providers.view',
'technique.providers.manage',
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
'technique.providers.archive',
],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
},
+16 -1
View File
@@ -231,7 +231,6 @@ 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
@@ -250,6 +249,22 @@ sync-permissions:
seed-rbac:
$(SYMFONY_CONSOLE) --no-interaction app:seed-rbac
# Synchronise le referentiel des transporteurs QUALIMAT (ERP-39) : upsert sur
# le SIRET + soft-delete des absents + journal. Idempotent (refresh complet),
# prevu pour un cron quotidien.
# Options : --dry-run (analyse sans ecriture), --file=<chemin.json> (source
# locale au lieu de l'API), --ppp=<n> (taille de page API, defaut 10000).
qualimat-sync:
$(SYMFONY_CONSOLE) --no-interaction app:qualimat:sync
# Synchronise le referentiel des codes IDTF (ERP-149) depuis l'export Excel
# icrt-idtf.com : upsert sur (schema, idtf_number) + soft-delete + journal.
# Idempotent (refresh complet).
# Options : --schema=road|water (defaut road), --dry-run (analyse sans
# ecriture), --file=<chemin.xlsx> (source locale au lieu du telechargement).
idtf-sync:
$(SYMFONY_CONSOLE) --no-interaction app:idtf:sync
# Attention, supprime votre bdd local
db-reset:
$(DOCKER_COMPOSE) down -v
-121
View File
@@ -1,121 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M3 (ticket 1.1) Taxonomie PRESTATAIRE (module Catalog, prerequis du module Technique).
*
* Contexte : le M3 (repertoire prestataires) a besoin d'une taxonomie distincte
* des types CLIENT (M1) et FOURNISSEUR (M2). Decision Matthieu (11/06) : types
* distincts CLIENT / FOURNISSEUR / PRESTATAIRE, chacun avec sa taxonomie. Le
* multi-select « Categorie » du prestataire (formulaire principal + adresse)
* ne reference que des `Category` rattachees au type PRESTATAIRE (RG-3.09).
*
* Cette migration :
* 1. cree le `category_type` PRESTATAIRE (code PRESTATAIRE, label « Prestataire ») ;
* 2. seede 3 `Category` de demonstration rattachees a ce type via la jonction
* ManyToMany `category_category_type` (modele courant depuis Version20260608120000 ;
* la colonne ManyToOne `category.category_type_id` n'existe plus).
*
* Aucune colonne creee/modifiee -> pas de `COMMENT ON COLUMN` (regle ABSOLUE n°12) :
* la migration ne fait que des INSERT de donnees de reference.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
* avec plusieurs migrations_paths, Doctrine Migrations 3.x trie par FQCN
* alphabetique -> une migration `App\Module\...` passerait avant les
* `DoctrineMigrations\...` sur base vide, donc avant la creation des tables
* `category` / `category_type` / `category_category_type`. Le namespace racine
* garantit l'ordre par timestamp.
*
* Idempotence : `INSERT ... ON CONFLICT (code) DO NOTHING` pour le type,
* `INSERT ... SELECT ... WHERE NOT EXISTS` pour chaque categorie et chaque ligne
* de jonction (aligne sur le pattern ERP-84 / Version20260605120000). En prod la
* table `category` est vide (aucune fixture metier). En dev/test, le purger
* Doctrine vide `category` / `category_type` avant les fixtures qui reproduisent
* le meme etat final (CategoryTypeFixtures / CategoryFixtures etendus a PRESTATAIRE).
*/
final class Version20260612080000 extends AbstractMigration
{
/**
* Categories de demonstration du type PRESTATAIRE : nom => code stable. Le
* code est la cle metier (slug MAJUSCULE du nom, miroir du
* CategoryCodeGenerator) et reste unique parmi les actifs (uq_category_code,
* partage avec les codes CLIENT / FOURNISSEUR aucune collision ici). Le nom
* est unique GLOBALEMENT parmi les actifs (uq_category_name_active) : les
* libelles ci-dessous n'entrent en collision avec aucune categorie seedee.
*/
private const array PROVIDER_CATEGORIES = [
'Maintenance industrielle' => 'MAINTENANCE_INDUSTRIELLE',
'Nettoyage' => 'NETTOYAGE',
'Transport' => 'TRANSPORT',
];
public function getDescription(): string
{
return 'M3 1.1 : cree le CategoryType PRESTATAIRE + seed des categories prestataires (Maintenance industrielle, Nettoyage, Transport).';
}
public function up(Schema $schema): void
{
// 1. Type PRESTATAIRE (idempotent via l'index unique uq_category_type_code).
$this->addSql(<<<'SQL'
INSERT INTO category_type (code, label) VALUES ('PRESTATAIRE', 'Prestataire')
ON CONFLICT (code) DO NOTHING
SQL);
foreach (self::PROVIDER_CATEGORIES as $name => $code) {
// 2a. Categorie sous PRESTATAIRE (si le code est libre parmi les
// actifs). created_at/updated_at NOT NULL -> NOW() ; le blame
// reste null (seed hors contexte HTTP, libelle « Systeme » cote front).
$this->addSql(<<<'SQL'
INSERT INTO category (name, code, created_at, updated_at)
SELECT :name, :code, NOW(), NOW()
WHERE NOT EXISTS (
SELECT 1 FROM category c WHERE c.code = :code AND c.deleted_at IS NULL
)
SQL, ['name' => $name, 'code' => $code]);
// 2b. Jonction M2M categorie <-> type PRESTATAIRE (modele courant).
$this->addSql(<<<'SQL'
INSERT INTO category_category_type (category_id, category_type_id)
SELECT c.id, ct.id
FROM category c
CROSS JOIN category_type ct
WHERE c.code = :code AND c.deleted_at IS NULL
AND ct.code = 'PRESTATAIRE'
AND NOT EXISTS (
SELECT 1 FROM category_category_type cct
WHERE cct.category_id = c.id AND cct.category_type_id = ct.id
)
SQL, ['code' => $code]);
}
}
public function down(Schema $schema): void
{
// Best-effort : on retire d'abord les categories seedees (par code) — la FK
// category_category_type est ON DELETE CASCADE cote category, donc les
// lignes de jonction partent avec —, puis le type s'il n'est plus reference.
$this->addSql(
'DELETE FROM category WHERE code IN (:codes) '
."AND id IN (SELECT category_id FROM category_category_type cct "
."JOIN category_type ct ON ct.id = cct.category_type_id WHERE ct.code = 'PRESTATAIRE')",
['codes' => array_values(self::PROVIDER_CATEGORIES)],
['codes' => ArrayParameterType::STRING],
);
$this->addSql(<<<'SQL'
DELETE FROM category_type
WHERE code = 'PRESTATAIRE'
AND NOT EXISTS (
SELECT 1 FROM category_category_type cct WHERE cct.category_type_id = category_type.id
)
SQL);
}
}
-451
View File
@@ -1,451 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M3 Repertoire prestataires (ERP-132) : creation de toute la structure BDD
* des prestataires sous le nouveau module Technique (jumeau du M2 fournisseur).
*
* Tables creees :
* - Table principale : provider (formulaire principal + Comptabilite + archive
* + soft-delete + Timestampable/Blamable). PAS d onglet Information.
* - M2M du formulaire principal : provider_category (RG-3.09),
* provider_site (sites du prestataire, RG-3.03 NOUVEAU vs supplier).
* - Sous-collections : provider_contact (1:n), provider_address (1:n),
* provider_rib (1:n).
* - Jointures de provider_address : provider_address_site (RG-3.05),
* provider_address_contact, provider_address_category.
*
* Differences vs le M2 `supplier` (cf. spec M3 § 3.1) :
* - PAS d onglet Information : aucun champ description / competitors /
* founded_at / employees_count / revenue_amount / director_name /
* profit_amount / volume_forecast. Le provider est minimal : nom + compta.
* - AJOUT de provider_site (M2M) : sites rattaches au prestataire directement
* sur le formulaire principal (RG-3.03, >= 1). Sert aussi le cloisonnement
* par site (idx_provider_site_site, § 2.13).
* - provider_address SIMPLIFIEE : pas de address_type / bennes /
* triage_provider (specifiques fournisseur). Champs : country / postal_code
* / city / street / street_complement / position + M2M sites/contacts/categories.
*
* Referentiels comptables NON recrees : tva_mode / payment_delay / payment_type
* / bank sont ceux du M1 (FK partagees, zero duplication spec § 2.3).
*
* CategoryType PRESTATAIRE NON re-seede : il est cree par ERP-131
* (Version20260612080000) avec ses categories de demonstration. Le M2M
* provider_category / provider_address_category s appuie sur ce type existant.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et NON
* `App\Module\Technique\...` : la migration cree un schema avec FK cross-module
* (user, category, site, et les referentiels comptables M1). Avec plusieurs
* migrations_paths, Doctrine Migrations 3.x trie par FQCN alphabetique un
* namespace modulaire s executerait avant la creation de user/category/site sur
* base vide -> echec des FK. Le namespace racine garantit l ordre par timestamp.
*
* Style DDL aligne sur le M1/M2 (Version20260605130000) : `INT GENERATED BY
* DEFAULT AS IDENTITY` (et non SERIAL), `TIMESTAMP(0) WITHOUT TIME ZONE` (et non
* TIMESTAMPTZ, car le TimestampableBlamableTrait mappe `datetime_immutable`).
* Garantit que `schema:update` restera un no-op quand les entites arriveront
* (ticket ERP-133).
*
* Decision unicite (alignee Q4 M1 / § 2.6 M2) : unicite metier sur le NOM DE
* SOCIETE uniquement (uq_provider_company_name_active, partiel). Pas d index
* unique sur siren ni email.
*
* COMMENT ON COLUMN inline (regle ABSOLUE n°12) : chaque colonne metier porte sa
* description ici-meme. Volontairement NON ajoutees a `ColumnCommentsCatalog` /
* `makefile test-db-setup` a ce stade : tant que les entites Provider* n existent
* pas (ERP-133), `schema:update --force` du setup de test droppe ces tables non
* mappees les referencer dans le catalogue ferait planter
* `app:apply-column-comments`. Le catalogue + la ligne `dbal:run-sql`
* (uq_provider_company_name_active) seront ajoutes au ticket entites (ERP-133),
* exactement comme supplier (ERP-86) apres sa migration (ERP-85). Les 4 colonnes
* Timestampable/Blamable reutilisent les textes standardises du catalogue
* (`timestampableBlamableComments()`, simple tableau statique sans dependance DB).
*/
final class Version20260612100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-132 (M3) : tables provider + sous-collections + jointures M2M (referentiels comptables et CategoryType PRESTATAIRE reutilises).';
}
public function up(Schema $schema): void
{
$this->createProviderTable();
$this->createProviderCategory();
$this->createProviderSite();
$this->createProviderContact();
$this->createProviderAddress();
$this->createProviderAddressJoinTables();
$this->createProviderRib();
}
public function down(Schema $schema): void
{
// Ordre inverse des dependances FK : jointures et sous-collections
// d abord, puis provider. Les referentiels comptables et le
// CategoryType PRESTATAIRE ne sont pas touches (crees ailleurs).
$this->addSql('DROP TABLE IF EXISTS provider_address_category');
$this->addSql('DROP TABLE IF EXISTS provider_address_contact');
$this->addSql('DROP TABLE IF EXISTS provider_address_site');
$this->addSql('DROP TABLE IF EXISTS provider_rib');
$this->addSql('DROP TABLE IF EXISTS provider_address');
$this->addSql('DROP TABLE IF EXISTS provider_contact');
$this->addSql('DROP TABLE IF EXISTS provider_site');
$this->addSql('DROP TABLE IF EXISTS provider_category');
$this->addSql('DROP TABLE IF EXISTS provider');
}
// =================================================================
// Table principale `provider`
// =================================================================
private function createProviderTable(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE provider (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
company_name VARCHAR(180) NOT NULL,
siren VARCHAR(20) DEFAULT NULL,
account_number VARCHAR(40) DEFAULT NULL,
tva_mode_id INT DEFAULT NULL,
n_tva VARCHAR(40) DEFAULT NULL,
payment_delay_id INT DEFAULT NULL,
payment_type_id INT DEFAULT NULL,
bank_id INT DEFAULT NULL,
is_archived BOOLEAN DEFAULT FALSE NOT NULL,
archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_provider_tva_mode
FOREIGN KEY (tva_mode_id) REFERENCES tva_mode (id) ON DELETE RESTRICT,
CONSTRAINT fk_provider_payment_delay
FOREIGN KEY (payment_delay_id) REFERENCES payment_delay (id) ON DELETE RESTRICT,
CONSTRAINT fk_provider_payment_type
FOREIGN KEY (payment_type_id) REFERENCES payment_type (id) ON DELETE RESTRICT,
CONSTRAINT fk_provider_bank
FOREIGN KEY (bank_id) REFERENCES bank (id) ON DELETE RESTRICT,
CONSTRAINT fk_provider_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_provider_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_provider_is_archived ON provider (is_archived)');
$this->addSql('CREATE INDEX idx_provider_deleted_at ON provider (deleted_at)');
$this->addSql('CREATE INDEX idx_provider_created_by ON provider (created_by)');
$this->addSql('CREATE INDEX idx_provider_updated_by ON provider (updated_by)');
// Index sur les FK des referentiels comptables (Postgres n indexe pas
// automatiquement les colonnes portant une FOREIGN KEY).
$this->addSql('CREATE INDEX idx_provider_tva_mode_id ON provider (tva_mode_id)');
$this->addSql('CREATE INDEX idx_provider_payment_delay_id ON provider (payment_delay_id)');
$this->addSql('CREATE INDEX idx_provider_payment_type_id ON provider (payment_type_id)');
$this->addSql('CREATE INDEX idx_provider_bank_id ON provider (bank_id)');
// Unicite metier partielle : nom de societe insensible a la casse, parmi
// les non-archives ET non soft-deletes uniquement (RG-3.10). Pas d index
// unique sur siren ni email.
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX uq_provider_company_name_active
ON provider (LOWER(company_name))
WHERE is_archived = FALSE AND deleted_at IS NULL
SQL);
$this->comment('provider', '_table', 'Repertoire prestataires (M3 Technique) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M4). Pas d onglet Information (≠ supplier).');
$this->comment('provider', 'id', 'Identifiant interne auto-incremente.');
$this->comment('provider', '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).');
$this->comment('provider', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-3.10).');
$this->comment('provider', 'account_number', 'Onglet Comptabilite : numero de compte comptable du prestataire.');
$this->comment('provider', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id (referentiel partage M1), ON DELETE RESTRICT.');
$this->comment('provider', 'n_tva', 'Onglet Comptabilite : numero de TVA intracommunautaire.');
$this->comment('provider', 'payment_delay_id', 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id (M1), ON DELETE RESTRICT.');
$this->comment('provider', '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).');
$this->comment('provider', 'bank_id', 'Onglet Comptabilite : banque — FK -> bank.id (M1), ON DELETE RESTRICT. Obligatoire ssi payment_type = VIREMENT (RG-3.07), null sinon.');
$this->comment('provider', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission technique.providers.archive.');
$this->comment('provider', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.');
$this->comment('provider', 'deleted_at', 'Horodatage du soft-delete technique (HP M4) — non expose par l API au M3. Null = ligne active.');
$this->addTimestampableBlamableComments('provider');
}
// =================================================================
// M2M provider <-> category (type PRESTATAIRE — RG-3.09)
// =================================================================
private function createProviderCategory(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE provider_category (
provider_id INT NOT NULL,
category_id INT NOT NULL,
PRIMARY KEY (provider_id, category_id),
CONSTRAINT fk_provider_category_provider
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_category_category
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
)
SQL);
$this->addSql('CREATE INDEX idx_provider_category_category ON provider_category (category_id)');
$this->comment('provider_category', '_table', 'Jointure M2M provider <-> category (Catalog) — categories de type PRESTATAIRE du prestataire, au moins une obligatoire (RG-3.09).');
$this->comment('provider_category', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur de la categorie.');
$this->comment('provider_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie de type PRESTATAIRE rattachee au prestataire (RG-3.09).');
}
// =================================================================
// M2M provider <-> site (formulaire principal — RG-3.03)
// =================================================================
private function createProviderSite(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE provider_site (
provider_id INT NOT NULL,
site_id INT NOT NULL,
PRIMARY KEY (provider_id, site_id),
CONSTRAINT fk_provider_site_provider
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_site_site
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
)
SQL);
// Index sur site_id : sert le filtre de cloisonnement par site
// (WHERE site = :currentSite, § 2.13).
$this->addSql('CREATE INDEX idx_provider_site_site ON provider_site (site_id)');
$this->comment('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).');
$this->comment('provider_site', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur du site.');
$this->comment('provider_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache au prestataire (RG-3.03, idx_provider_site_site).');
}
// =================================================================
// Sous-collection : contacts (1:n)
// =================================================================
private function createProviderContact(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE provider_contact (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
provider_id INT NOT NULL,
first_name VARCHAR(120) DEFAULT NULL,
last_name VARCHAR(120) DEFAULT NULL,
job_title VARCHAR(120) DEFAULT NULL,
phone_primary VARCHAR(20) DEFAULT NULL,
phone_secondary VARCHAR(20) DEFAULT NULL,
email VARCHAR(180) DEFAULT NULL,
position INT DEFAULT 0 NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT chk_provider_contact_name
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL),
CONSTRAINT fk_provider_contact_provider
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_contact_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_provider_contact_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_provider_contact_provider ON provider_contact (provider_id)');
$this->comment('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).');
$this->comment('provider_contact', 'id', 'Identifiant interne auto-incremente.');
$this->comment('provider_contact', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.');
$this->comment('provider_contact', 'first_name', 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
$this->comment('provider_contact', 'last_name', 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
$this->comment('provider_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).');
$this->comment('provider_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (normalisation serveur).');
$this->comment('provider_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).');
$this->comment('provider_contact', 'email', 'Email du contact (lowercase serveur).');
$this->comment('provider_contact', 'position', 'Ordre d affichage du contact dans la liste du prestataire (croissant).');
$this->addTimestampableBlamableComments('provider_contact');
}
// =================================================================
// Sous-collection : adresses (1:n) — SANS address_type / bennes / triage
// =================================================================
private function createProviderAddress(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE provider_address (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
provider_id INT NOT NULL,
country VARCHAR(80) DEFAULT 'France' NOT NULL,
postal_code VARCHAR(20) NOT NULL,
city VARCHAR(120) NOT NULL,
street VARCHAR(255) NOT NULL,
street_complement VARCHAR(255) DEFAULT NULL,
position INT DEFAULT 0 NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_provider_address_provider
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_address_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_provider_address_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_provider_address_provider ON provider_address (provider_id)');
$this->comment('provider_address', '_table', 'Adresses d un prestataire (1:n) — >= 1 site rattache (RG-3.05). SANS address_type / bennes / triage_provider (specifiques fournisseur).');
$this->comment('provider_address', 'id', 'Identifiant interne auto-incremente.');
$this->comment('provider_address', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire de l adresse.');
$this->comment('provider_address', 'country', 'Pays de l adresse — defaut France.');
$this->comment('provider_address', 'postal_code', 'Code postal (4-5 chiffres attendus) — declenche l autocompletion ville via l API BAN cote front (RG-3.06).');
$this->comment('provider_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front.');
$this->comment('provider_address', 'street', 'Numero et voie de l adresse.');
$this->comment('provider_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.');
$this->comment('provider_address', 'position', 'Ordre d affichage de l adresse dans la liste du prestataire (croissant).');
$this->addTimestampableBlamableComments('provider_address');
}
// =================================================================
// Jointures de provider_address (M2M)
// =================================================================
private function createProviderAddressJoinTables(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE provider_address_site (
provider_address_id INT NOT NULL,
site_id INT NOT NULL,
PRIMARY KEY (provider_address_id, site_id),
CONSTRAINT fk_provider_address_site_address
FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_address_site_site
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
)
SQL);
$this->comment('provider_address_site', '_table', 'Jointure M2M provider_address <-> site (Sites) — sites rattaches a l adresse (>= 1 obligatoire, RG-3.05).');
$this->comment('provider_address_site', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
$this->comment('provider_address_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.');
$this->addSql(<<<'SQL'
CREATE TABLE provider_address_contact (
provider_address_id INT NOT NULL,
provider_contact_id INT NOT NULL,
PRIMARY KEY (provider_address_id, provider_contact_id),
CONSTRAINT fk_provider_address_contact_address
FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_address_contact_contact
FOREIGN KEY (provider_contact_id) REFERENCES provider_contact (id) ON DELETE CASCADE
)
SQL);
$this->comment('provider_address_contact', '_table', 'Jointure M2M provider_address <-> provider_contact — contacts associes a une adresse.');
$this->comment('provider_address_contact', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
$this->comment('provider_address_contact', 'provider_contact_id', 'FK -> provider_contact.id, ON DELETE CASCADE — contact associe a l adresse.');
$this->addSql(<<<'SQL'
CREATE TABLE provider_address_category (
provider_address_id INT NOT NULL,
category_id INT NOT NULL,
PRIMARY KEY (provider_address_id, category_id),
CONSTRAINT fk_provider_address_category_address
FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_address_category_category
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
)
SQL);
$this->comment('provider_address_category', '_table', 'Jointure M2M provider_address <-> category — categories d adresse de type PRESTATAIRE (RG-3.09).');
$this->comment('provider_address_category', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
$this->comment('provider_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type PRESTATAIRE (RG-3.09).');
}
// =================================================================
// Sous-collection : RIB (1:n)
// =================================================================
private function createProviderRib(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE provider_rib (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
provider_id INT NOT NULL,
label VARCHAR(120) NOT NULL,
bic VARCHAR(20) NOT NULL,
iban VARCHAR(34) NOT NULL,
position INT DEFAULT 0 NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_provider_rib_provider
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_rib_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_provider_rib_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_provider_rib_provider ON provider_rib (provider_id)');
$this->comment('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).');
$this->comment('provider_rib', 'id', 'Identifiant interne auto-incremente.');
$this->comment('provider_rib', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du RIB.');
$this->comment('provider_rib', 'label', 'Libelle du RIB (ex: compte principal).');
$this->comment('provider_rib', 'bic', 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).');
$this->comment('provider_rib', 'iban', 'IBAN du compte (≤ 34 caracteres).');
$this->comment('provider_rib', 'position', 'Ordre d affichage du RIB dans la liste du prestataire (croissant).');
$this->addTimestampableBlamableComments('provider_rib');
}
// =================================================================
// Helpers
// =================================================================
/**
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
* en reutilisant le catalogue partage (source unique, cf. ERP-67). Seul le
* tableau statique des textes est reutilise aucune dependance a l etat DB.
*/
private function addTimestampableBlamableComments(string $table): void
{
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
$this->comment($table, $column, $description);
}
}
/**
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou
* `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour eviter
* tout echappement d apostrophe.
*/
private function comment(string $table, string $column, string $description): void
{
$quotedTable = '"'.str_replace('"', '""', $table).'"';
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
return;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-39 (Module Transport) : referentiel des transporteurs agrees QUALIMAT.
*
* Tables alimentees par la commande de synchronisation `app:qualimat:sync`
* (upsert sur le SIRET + soft-delete des absents + journal). Aucune FK
* cross-module (referentiel autonome) : migration posee au namespace racine
* `DoctrineMigrations`, comme les autres migrations de creation de tables.
*/
final class Version20260612150000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-39 : tables qualimat_carrier + qualimat_sync_log (referentiel transporteurs QUALIMAT, synchro console).';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE qualimat_carrier (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
siret VARCHAR(20) NOT NULL,
name VARCHAR(255) NOT NULL,
address VARCHAR(255) DEFAULT NULL,
postal_code VARCHAR(10) DEFAULT NULL,
city VARCHAR(255) DEFAULT NULL,
phone VARCHAR(32) DEFAULT NULL,
department VARCHAR(64) DEFAULT NULL,
status VARCHAR(32) NOT NULL,
validity_date DATE DEFAULT NULL,
is_active BOOLEAN DEFAULT TRUE NOT NULL,
last_synced_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uq_qualimat_carrier_siret UNIQUE (siret)
)
SQL);
$this->addSql('CREATE INDEX idx_qualimat_carrier_active ON qualimat_carrier (is_active)');
$this->comment('qualimat_carrier', '_table', "Referentiel des transporteurs agrees QUALIMAT, synchronise quotidiennement depuis l'API qualimat.org (type=operateur_transport).");
$this->comment('qualimat_carrier', 'id', 'Cle technique auto-incrementee.');
$this->comment('qualimat_carrier', 'siret', 'SIRET normalise (chiffres sans espaces). Cle naturelle de synchro (unique). Source parfois incomplete (longueur variable), non contrainte a 14.');
$this->comment('qualimat_carrier', 'name', 'Raison sociale du transporteur (champs Nom = Societe de la source, identiques).');
$this->comment('qualimat_carrier', 'address', 'Adresse postale (voie). Nullable.');
$this->comment('qualimat_carrier', 'postal_code', 'Code postal. Nullable.');
$this->comment('qualimat_carrier', 'city', 'Ville. Nullable.');
$this->comment('qualimat_carrier', 'phone', 'Telephone au format source "indicatif|numero" (ex: +33|0608890316). Nullable.');
$this->comment('qualimat_carrier', 'department', 'Departement au format source "code - libelle" (ex: 65 - Hautes-Pyrenees). Nullable.');
$this->comment('qualimat_carrier', 'status', "Statut d'agrement QUALIMAT (valeurs connues : Audite, Valide, Suspendu). Valeur brute de la source, non contrainte.");
$this->comment('qualimat_carrier', 'validity_date', 'Date de fin de validite de la certification (convertie depuis dd/mm/yyyy). Nullable.');
$this->comment('qualimat_carrier', 'is_active', 'Faux = transporteur absent du dernier import (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.');
$this->comment('qualimat_carrier', 'last_synced_at', 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).');
$this->addSql(<<<'SQL'
CREATE TABLE qualimat_sync_log (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
fetched_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
rows_total INT NOT NULL,
rows_upserted INT NOT NULL,
rows_skipped INT NOT NULL,
rows_deactivated INT NOT NULL,
created_at TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW() NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->comment('qualimat_sync_log', '_table', 'Journal des synchronisations QUALIMAT (une ligne par run de la commande app:qualimat:sync).');
$this->comment('qualimat_sync_log', 'id', 'Cle technique auto-incrementee.');
$this->comment('qualimat_sync_log', 'fetched_at', "Horodatage de l'appel a l'API source (= run de synchro).");
$this->comment('qualimat_sync_log', 'rows_total', "Nombre d'items renvoyes par l'API.");
$this->comment('qualimat_sync_log', 'rows_upserted', 'Nombre de transporteurs inseres ou mis a jour.');
$this->comment('qualimat_sync_log', 'rows_skipped', "Nombre d'items ignores (sans SIRET exploitable).");
$this->comment('qualimat_sync_log', 'rows_deactivated', 'Nombre de transporteurs passes a is_active=false (absents de cet import).');
$this->comment('qualimat_sync_log', 'created_at', 'Horodatage de fin du run (insertion du journal).');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS qualimat_sync_log');
$this->addSql('DROP TABLE IF EXISTS qualimat_carrier');
}
/**
* Pose un COMMENT ON TABLE/COLUMN en dollar-quoting Postgres ($_$...$_$)
* pour eviter tout echappement d'apostrophes dans les descriptions.
*/
private function comment(string $table, string $column, string $description): void
{
$quotedTable = '"'.str_replace('"', '""', $table).'"';
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
return;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-149 (Module Transport) : referentiel des codes IDTF (regimes de nettoyage
* transport).
*
* Tables alimentees par la commande `app:idtf:sync` (parsing de l'export Excel
* icrt-idtf.com, upsert sur (schema, idtf_number) + soft-delete + journal).
* Aucune FK cross-module : migration au namespace racine `DoctrineMigrations`.
*/
final class Version20260612160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-149 : tables idtf_product + idtf_sync_log (referentiel codes IDTF, synchro console depuis l\'export Excel).';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE idtf_product (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
idtf_number INTEGER NOT NULL,
schema VARCHAR(8) NOT NULL,
product_group VARCHAR(255) DEFAULT NULL,
name TEXT NOT NULL,
cleaning_regime VARCHAR(64) NOT NULL,
important_requirements TEXT DEFAULT NULL,
mandatory_date DATE DEFAULT NULL,
related_products TEXT DEFAULT NULL,
formula VARCHAR(255) DEFAULT NULL,
eural_code VARCHAR(64) DEFAULT NULL,
cas_numbers JSONB DEFAULT '[]' NOT NULL,
footnotes TEXT DEFAULT NULL,
source_export_date DATE NOT NULL,
is_active BOOLEAN DEFAULT TRUE NOT NULL,
last_synced_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uq_idtf_product_schema_number UNIQUE (schema, idtf_number),
CONSTRAINT chk_idtf_product_schema CHECK (schema IN ('road', 'water'))
)
SQL);
$this->addSql('CREATE INDEX idx_idtf_product_active ON idtf_product (schema, is_active)');
$this->comment('idtf_product', '_table', "Referentiel des codes IDTF (marchandise + regime de nettoyage transport), synchronise depuis l'export Excel icrt-idtf.com.");
$this->comment('idtf_product', 'id', 'Cle technique auto-incrementee.');
$this->comment('idtf_product', 'idtf_number', 'Numero IDTF de la marchandise (identifiant metier source). Unique par schema.');
$this->comment('idtf_product', 'schema', "Mode de transport / schema IDTF : 'road' (routier) ou 'water' (fluvial). Discriminant d'unicite avec idtf_number.");
$this->comment('idtf_product', 'product_group', "Groupe de produit (colonne Product Group de l'export). Nullable.");
$this->comment('idtf_product', 'name', "Nom de la marchandise (libelle FR de l'export).");
$this->comment('idtf_product', 'cleaning_regime', 'Regime de nettoyage minimal exige (A, B, C, Interdit, ...).');
$this->comment('idtf_product', 'important_requirements', 'Exigences importantes associees. Nullable.');
$this->comment('idtf_product', 'mandatory_date', "Date d'application obligatoire du regime (convertie depuis dd-mm-yyyy). Nullable.");
$this->comment('idtf_product', 'related_products', 'Produits apparentes (texte libre). Nullable.');
$this->comment('idtf_product', 'formula', 'Formule chimique de la marchandise. Nullable.');
$this->comment('idtf_product', 'eural_code', 'Code EURAL (dechet) associe. Nullable.');
$this->comment('idtf_product', 'cas_numbers', 'Liste des numeros CAS (JSONB), eclatee depuis la cellule "Numero CAS" separee par ";". Tableau vide si absent.');
$this->comment('idtf_product', 'footnotes', "Annotations / notes de bas de page de l'export. Nullable.");
$this->comment('idtf_product', 'source_export_date', 'Date d\'export du fichier source (preambule "Export date:").');
$this->comment('idtf_product', 'is_active', 'Faux = ligne absente du dernier export (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.');
$this->comment('idtf_product', 'last_synced_at', 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).');
$this->addSql(<<<'SQL'
CREATE TABLE idtf_sync_log (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
schema VARCHAR(8) NOT NULL,
export_date DATE NOT NULL,
rows_total INT NOT NULL,
rows_upserted INT NOT NULL,
rows_deactivated INT NOT NULL,
created_at TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW() NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->comment('idtf_sync_log', '_table', 'Journal des synchronisations IDTF (une ligne par run de la commande app:idtf:sync).');
$this->comment('idtf_sync_log', 'id', 'Cle technique auto-incrementee.');
$this->comment('idtf_sync_log', 'schema', "Mode de transport synchronise : 'road' ou 'water'.");
$this->comment('idtf_sync_log', 'export_date', "Date d'export du fichier source traite par ce run.");
$this->comment('idtf_sync_log', 'rows_total', 'Nombre de lignes exploitables lues dans le fichier.');
$this->comment('idtf_sync_log', 'rows_upserted', 'Nombre de lignes inserees ou mises a jour.');
$this->comment('idtf_sync_log', 'rows_deactivated', 'Nombre de lignes passees a is_active=false (absentes de cet export).');
$this->comment('idtf_sync_log', 'created_at', 'Horodatage de fin du run (insertion du journal).');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS idtf_sync_log');
$this->addSql('DROP TABLE IF EXISTS idtf_product');
}
/**
* Pose un COMMENT ON TABLE/COLUMN en dollar-quoting Postgres ($_$...$_$)
* pour eviter tout echappement d'apostrophes dans les descriptions.
*/
private function comment(string $table, string $column, string $description): void
{
$quotedTable = '"'.str_replace('"', '""', $table).'"';
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
return;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
@@ -17,9 +17,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
* Fixtures dev/test du module Catalog : categories de demonstration rattachees
* a leur CategoryType. Le type CLIENT porte ~11 categories clients (refonte
* taxonomie ERP-78) ; le type FOURNISSEUR porte les categories fournisseurs
* (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories
* prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport). Chaque
* categorie porte un `code` stable.
* (ERP-84 : Negociant, Cooperative...). Chaque categorie porte un `code` stable.
* Alimente le repertoire clients (ClientFixtures, module Commercial) avec des
* donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29
* (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2).
@@ -73,11 +71,6 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
'Grossiste' => 'GROSSISTE',
'Importateur' => 'IMPORTATEUR',
],
'PRESTATAIRE' => [
'Maintenance industrielle' => 'MAINTENANCE_INDUSTRIELLE',
'Nettoyage' => 'NETTOYAGE',
'Transport' => 'TRANSPORT',
],
];
public function __construct(
@@ -21,10 +21,6 @@ use Doctrine\Persistence\ObjectManager;
* taxonomie distincte des fournisseurs (Negociant, Cooperative...). Mirroir de
* la migration Version20260605120000.
*
* M3 1.1 : ajout du type PRESTATAIRE (code PRESTATAIRE, label « Prestataire »),
* taxonomie distincte des prestataires (Maintenance industrielle, Nettoyage,
* Transport). Mirroir de la migration Version20260612080000.
*
* Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une
* entite managee par l ORM, donc le purger Doctrine la vide avant chaque
* `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la
@@ -40,13 +36,12 @@ class CategoryTypeFixtures extends Fixture
{
/**
* Source unique des types : code technique => libelle FR. Doit rester aligne
* sur le seed des migrations Version20260602100000 (CLIENT),
* Version20260605120000 (FOURNISSEUR) et Version20260612080000 (PRESTATAIRE).
* sur le seed des migrations Version20260602100000 (CLIENT) et
* Version20260605120000 (FOURNISSEUR).
*/
private const TYPES = [
'CLIENT' => 'Client',
'FOURNISSEUR' => 'Fournisseur',
'PRESTATAIRE' => 'Prestataire',
];
public function __construct(
@@ -22,8 +22,10 @@ use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -94,6 +96,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
security: "is_granted('commercial.clients.manage')",
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
denormalizationContext: ['groups' => ['client:write:main']],
// Une valeur de mauvais type (ex. date non parsable sur foundedAt)
// doit produire un 422 porte sur le champ (violations[].propertyPath,
// mappable inline par useFormErrors) plutot qu'un 400 generique non
// exploitable. Le front (MalioDate, MUI-44) forwarde la saisie brute
// invalide : le back reste la couche autoritaire du format (ERP-101).
collectDenormalizationErrors: true,
processor: ClientProcessor::class,
),
new Patch(
@@ -117,6 +125,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
'client:write:accounting',
'client:write:archive',
]],
// Cf. Post : date non parsable (foundedAt) -> 422 porte sur le champ
// au lieu d'un 400 generique. Indispensable au mapping inline du
// front (MalioDate MUI-44 forwarde la saisie brute invalide).
collectDenormalizationErrors: true,
provider: ClientProvider::class,
processor: ClientProcessor::class,
),
@@ -206,6 +218,13 @@ class Client implements TimestampableInterface, BlamableInterface
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['client:read', 'client:write:information'])]
// 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
// M/J/AAAA -> 25 decembre 2026, et accepte a tort. Avec le format, toute
// saisie brute non-ISO (forwardee par MalioDate sur date invalide) echoue la
// denormalisation -> 422 sur foundedAt (cf. collectDenormalizationErrors).
#[Context(denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
private ?DateTimeImmutable $foundedAt = null;
#[ORM\Column(nullable: true)]
@@ -22,8 +22,10 @@ use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -94,6 +96,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
security: "is_granted('commercial.suppliers.manage')",
normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']],
denormalizationContext: ['groups' => ['supplier:write:main']],
// Une valeur de mauvais type (ex. date non parsable sur foundedAt)
// doit produire un 422 porte sur le champ (violations[].propertyPath,
// mappable inline par useFormErrors) plutot qu'un 400 generique. Le
// front (MalioDate, MUI-44) forwarde la saisie brute invalide : le
// back reste la couche autoritaire du format (ERP-101). Cf. Client.
collectDenormalizationErrors: true,
processor: SupplierProcessor::class,
),
new Patch(
@@ -113,6 +121,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
'supplier:write:accounting',
'supplier:write:archive',
]],
// Cf. Post : date non parsable (foundedAt) -> 422 porte sur le champ
// au lieu d'un 400 generique. Indispensable au mapping inline du
// front (MalioDate MUI-44 forwarde la saisie brute invalide).
collectDenormalizationErrors: true,
provider: SupplierProvider::class,
processor: SupplierProcessor::class,
),
@@ -187,6 +199,11 @@ class Supplier implements TimestampableInterface, BlamableInterface
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['supplier:read', 'supplier:write:information'])]
// 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
// saisie brute non-ISO echoue -> 422 sur foundedAt. Cf. Client.
#[Context(denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
private ?DateTimeImmutable $foundedAt = null;
#[ORM\Column(nullable: true)]
@@ -50,19 +50,11 @@ final class RbacSeeder
/**
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
* attacher (admin n'apparait pas car il bypass tout via isAdmin ;
* `commercial.clients.archive`, `commercial.suppliers.archive` et
* `technique.providers.archive` ne sont attaches a aucun role metier
* attacher (vide pour usine : aucun acces ; admin n'apparait pas car il
* bypass tout via isAdmin ; `commercial.clients.archive` et
* `commercial.suppliers.archive` ne sont attaches a aucun role metier
* admin seul).
*
* Cloisonnement par site des prestataires (M3 § 2.13) : la permission
* `sites.bypass_scope` est attribuee par defaut a Bureau / Compta /
* Commerciale (ils voient « Tout », d'apres le docx) ; Usine ne l'a PAS et
* reste cloisonnee a son site courant. Admin a le bypass total via isAdmin.
* C'est un cloisonnement pilote par user/permission, pas par code de role :
* pour cloisonner Bureau/Commerciale, il suffit de retirer la permission
* ici, aucun autre code a changer.
*
* @var array<string, array{label: string, permissions: list<string>}>
*/
private const array MATRIX = [
@@ -74,11 +66,6 @@ final class RbacSeeder
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
'commercial.suppliers.view',
'commercial.suppliers.manage',
// Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite).
'technique.providers.view',
'technique.providers.manage',
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -95,13 +82,6 @@ final class RbacSeeder
'commercial.suppliers.view',
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
// Prestataires (M3 § 2.9, ERP-138) : view + onglet Comptabilite uniquement
// (pas de manage global -> ne peut pas creer un prestataire).
'technique.providers.view',
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -116,25 +96,14 @@ final class RbacSeeder
// (onglet Comptabilite masque/filtre pour la Commerciale).
'commercial.suppliers.view',
'commercial.suppliers.manage',
// Prestataires (M3 § 2.9, ERP-138) : view + manage, sans accounting
// (onglet Comptabilite masque/filtre pour la Commerciale).
'technique.providers.view',
'technique.providers.manage',
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
],
],
self::ROLE_USINE => [
'label' => 'Usine',
// Prestataires (M3 § 2.9 + § 2.13, ERP-138) : view en lecture seule,
// SANS `sites.bypass_scope` -> cloisonne aux prestataires de son site
// courant. Aucun autre acces metier.
'permissions' => [
'technique.providers.view',
],
'label' => 'Usine',
'permissions' => [],
],
];
@@ -203,15 +203,6 @@ final class SeedE2ECommand extends Command
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
'commercial.suppliers.archive',
// Technique — Repertoire prestataires (M3, ERP-138). Meme
// logique : mappe sur le persona "tout". user-full porte deja
// sites.bypass_scope -> voit les prestataires de tous les
// sites (M3 § 2.13). Miroir de personas.ts.
'technique.providers.view',
'technique.providers.manage',
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
'technique.providers.archive',
],
],
[
@@ -1,82 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Application\Service;
/**
* Normalisation serveur des champs texte d'un Provider / ProviderContact,
* appliquee par le ProviderProcessor (et les processors de sous-ressources,
* ticket ulterieur M3) AVANT persistance. Cf. spec-back M3 § 2.11 + RG-3.11.
* Jumeau de SupplierFieldNormalizer (M2) duplique volontairement (isolation
* Commercial / Technique, decision § 2.1).
*
* - companyName : UPPERCASE integral (RG-3.11)
* - firstName / lastName (personnes, sur ProviderContact) : Title Case (RG-3.11)
* - phone* : chiffres uniquement, ex "06.12.34.56.78" -> "0612345678" (RG-3.11).
* Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front.
* - email : lowercase integral (RG-3.11)
*
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide
* apres trim devient null (evite de persister "" dans des colonnes nullable).
*/
final class ProviderFieldNormalizer
{
/**
* Nom de societe en majuscules (RG-3.11). Conserve null tel quel ; une
* chaine non vide est trim + upper. Une chaine vide reste "" (champ
* obligatoire : c'est l'Assert\NotBlank qui rejette, pas le normalizer).
*/
public function normalizeCompanyName(?string $value): ?string
{
if (null === $value) {
return null;
}
return mb_strtoupper(trim($value), 'UTF-8');
}
/**
* Nom/prenom de personne en Title Case (RG-3.11) : "JEAN dupont" ->
* "Jean Dupont". Une chaine vide apres trim devient null.
*/
public function normalizePersonName(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8');
}
/**
* Email en minuscules (RG-3.11). Une chaine vide apres trim devient null.
*/
public function normalizeEmail(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
}
/**
* Telephone reduit aux chiffres (RG-3.11) : "06.12.34.56.78" ->
* "0612345678". Une valeur sans aucun chiffre devient null.
*/
public function normalizePhone(?string $value): ?string
{
if (null === $value) {
return null;
}
$digits = preg_replace('/\D+/', '', $value) ?? '';
return '' === $digits ? null : $digits;
}
}
@@ -1,607 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderProvider;
use App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository;
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 DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Prestataire (M3 Technique) entite racine du repertoire prestataires, jumelle
* du Fournisseur (M2). Porte le formulaire principal (nom + categories + sites),
* l'onglet Comptabilite, le mecanisme d'archivage (is_archived / archived_at) et
* le soft-delete technique prepare mais non expose au M3 (deleted_at, HP M4).
*
* Differences structurantes vs Supplier (cf. spec M3 § 3.1) :
* - PAS d'onglet Information : aucun champ description / competitors / founded_at
* / employees_count / revenue_amount / director_name / profit_amount /
* volume_forecast. Le prestataire est minimal : nom + comptabilite.
* - AJOUT de `sites` (M2M `provider_site`) : sites rattaches DIRECTEMENT au
* prestataire sur le formulaire principal (RG-3.03, >= 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] cable (ERP-134) le ProviderProvider
* (liste paginee anti-N+1, exclusion archives, cloisonnement site lecture + detail
* 404) et le ProviderProcessor (normalisation, archivage, mode strict par groupe,
* cloisonnement site ecriture, 409 doublon). Le groupe provider:read:accounting est
* ajoute dynamiquement au contexte par le ProviderReadGroupContextBuilder selon la
* permission accounting.view (ERP-134) jamais pose en dur sur l'operation.
*
* 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. Hydratation anti-N+1 cablee par
// le ProviderProvider (cf. DoctrineProviderRepository::hydrateListCollections).
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
provider: ProviderProvider::class,
),
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 est ajoute au
// contexte par le ProviderReadGroupContextBuilder selon la permission
// accounting.view (parade fuite IBAN/BIC — bug #4 M1).
normalizationContext: ['groups' => [
'provider:read',
'provider:item:read',
'category:read',
'site:read',
'default:read',
]],
provider: ProviderProvider::class,
),
new Post(
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
denormalizationContext: ['groups' => ['provider:write:main']],
processor: ProviderProcessor::class,
),
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',
]],
provider: ProviderProvider::class,
processor: ProviderProcessor::class,
),
// 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';
/** Code pivot du type de reglement imposant une banque (RG-3.07). */
private const string PAYMENT_TYPE_VIREMENT = 'VIREMENT';
/** Code pivot du type de reglement imposant au moins un RIB (RG-3.08). */
private const string PAYMENT_TYPE_LCR = 'LCR';
#[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<int, CategoryInterface> */
#[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<int, SiteInterface> */
#[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<int, ProviderContact> */
#[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contacts;
/** @var Collection<int, ProviderAddress> */
#[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $addresses;
/** @var Collection<int, ProviderRib> */
#[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;
}
}
}
/**
* RG-3.07 / RG-3.08 : coherence du type de reglement comptable. Comme au M2
* (decision figee ERP-89, jumeau Supplier::validatePaymentTypeConsistency),
* ces RG inter-champs passent par une contrainte d'entite (Assert\Callback +
* ->atPath()) et NON par le ProviderProcessor, afin que chaque 422 porte un
* propertyPath exploitable par extractApiViolations (mapping inline sous le
* champ, pas un toast convention ERP-101).
* - RG-3.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`.
* - RG-3.08 : paymentType = LCR impose au moins un RIB -> violation sur
* `paymentType` (les RIB n'ont pas de champ de formulaire ou s'ancrer quand
* la liste est vide ; l'erreur s'affiche donc sous le select « Type de
* règlement », binde cote front). Le 409 sur DELETE du dernier RIB en LCR est
* porte par le ProviderRibProcessor (ERP-135).
*
* Ces champs vivant dans le groupe d'ecriture comptable (absent du POST, qui
* n'expose que provider:write:main), la contrainte ne mord en pratique que sur
* le PATCH de l'onglet Comptabilite.
*/
#[Assert\Callback]
public function validatePaymentTypeConsistency(ExecutionContextInterface $context): void
{
$paymentCode = $this->paymentType?->getCode();
if (self::PAYMENT_TYPE_VIREMENT === $paymentCode && null === $this->bank) {
$context->buildViolation('La banque est obligatoire pour le type de règlement Virement.')
->atPath('bank')
->addViolation()
;
}
if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) {
$context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.')
->atPath('paymentType')
->addViolation()
;
}
}
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<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;
}
/** @return Collection<int, SiteInterface> */
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<int, ProviderContact> */
#[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<int, ProviderAddress> */
#[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<int, ProviderRib> */
#[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;
}
}
@@ -1,365 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderAddressProcessor;
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 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.
*
* 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)).
*
* Sous-ressource API (ERP-135, spec § 4.5) :
* - POST /api/providers/{providerId}/addresses : creation rattachee au prestataire
* parent (Link toProperty 'provider'), security technique.providers.manage.
* - PATCH / DELETE /api/provider_addresses/{id} : security technique.providers.manage.
* - 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
* contraintes de l'entite (jouees avant le processor).
*
* Audite (#[Auditable]) + Timestampable / Blamable.
*/
#[ApiResource(
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']],
),
new Post(
uriTemplate: '/providers/{providerId}/addresses',
uriVariables: [
'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
],
// read:false : pas de stade lecture du parent. Le Link toProperty
// resoudrait l'enfant (SELECT ProviderAddress ... WHERE provider = :id)
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
// 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']],
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']],
denormalizationContext: ['groups' => ['provider:write:addresses']],
processor: ProviderAddressProcessor::class,
),
new Delete(
security: "is_granted('technique.providers.manage')",
processor: ProviderAddressProcessor::class,
),
],
)]
#[ORM\Entity]
#[ORM\Table(name: 'provider_address')]
#[ORM\Index(name: 'idx_provider_address_provider', columns: ['provider_id'])]
#[Auditable]
class ProviderAddress implements TimestampableInterface, BlamableInterface
{
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]
#[Groups(['provider:item:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Provider::class, inversedBy: 'addresses')]
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Provider $provider = null;
#[ORM\Column(length: 80, options: ['default' => '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<int, SiteInterface> */
#[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<int, ProviderContact> */
#[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<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;
}
}
}
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<int, SiteInterface> */
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<int, ProviderContact> */
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<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;
}
}
@@ -1,236 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderContactProcessor;
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 Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Contact d'un prestataire (1:n) — onglet Contacts. Un bloc est valide des qu'au
* moins un champ est rempli (RG-3.04) : garantie portee par un CHECK BDD
* (chk_provider_contact_name) + le ProviderContactProcessor (ERP-135) ; l'entite
* reste permissive (tous les champs nullable).
*
* Embarque sous `provider.contacts` au detail (groupe provider:item:read,
* maillon (a) du contrat de serialisation). Maximum 2 telephones
* (phonePrimary + phoneSecondary).
*
* Sous-ressource API (ERP-135, spec § 4.5) :
* - POST /api/providers/{providerId}/contacts : creation rattachee au prestataire
* parent (Link toProperty 'provider'), security technique.providers.manage.
* - PATCH / DELETE /api/provider_contacts/{id} : security technique.providers.manage.
* Le DELETE est physique et libre (pas de garde « dernier contact » au M3
* RG-3.12 front-driven, la collection peut rester vide cote back).
* - GET /api/provider_contacts/{id} : lecture unitaire (security view) la lecture
* courante reste via le parent (le prestataire embarque ses contacts). Pas de GET
* collection autonome.
* Tout passe par le ProviderContactProcessor (normalisation RG-3.11, RG-3.04).
*
* Audite (#[Auditable]) + Timestampable / Blamable (pattern Shared standard).
*/
#[ApiResource(
operations: [
new Get(
security: "is_granted('technique.providers.view')",
normalizationContext: ['groups' => ['provider:item:read']],
),
new Post(
uriTemplate: '/providers/{providerId}/contacts',
uriVariables: [
'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
],
// read:false : pas de stade lecture du parent. Le Link toProperty
// resoudrait l'enfant (SELECT ProviderContact ... WHERE provider = :id)
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
// manuellement par ProviderContactProcessor::linkParent (404 si absent).
read: false,
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:item:read']],
denormalizationContext: ['groups' => ['provider:write:contacts']],
processor: ProviderContactProcessor::class,
),
new Patch(
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:item:read']],
denormalizationContext: ['groups' => ['provider:write:contacts']],
processor: ProviderContactProcessor::class,
),
new Delete(
security: "is_granted('technique.providers.manage')",
processor: ProviderContactProcessor::class,
),
],
)]
#[ORM\Entity]
#[ORM\Table(name: 'provider_contact')]
#[ORM\Index(name: 'idx_provider_contact_provider', columns: ['provider_id'])]
#[Auditable]
class ProviderContact implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['provider:item:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Provider::class, inversedBy: 'contacts')]
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Provider $provider = null;
// RG-3.04 : au moins un champ du contact renseigne (CHECK BDD + Processor). Les
// 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')]
#[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')]
#[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')]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $jobTitle = null;
// Pas de validation de format telephone (saisie libre), mais une Assert\Length
// calee sur la colonne VARCHAR(20) evite l'erreur Postgres (500 non rattachee au
// champ) au profit d'une 422 propre (ERP-107).
#[ORM\Column(length: 20, nullable: true)]
#[Assert\Length(max: 20, maxMessage: 'Le téléphone ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $phonePrimary = null;
#[ORM\Column(length: 20, nullable: true)]
#[Assert\Length(max: 20, maxMessage: 'Le téléphone secondaire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $phoneSecondary = null;
#[ORM\Column(length: 180, nullable: true)]
#[Assert\Email(message: 'L\'adresse email n\'est pas valide.')]
#[Assert\Length(max: 180, maxMessage: 'L\'email ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $email = null;
// Ordre d'affichage du contact (gere serveur, non expose au M3).
#[ORM\Column(options: ['default' => 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;
}
}
@@ -1,192 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderRibProcessor;
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 Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Coordonnees bancaires d'un prestataire (1:n) onglet Comptabilite. Au moins un
* RIB est obligatoire si le type de reglement est LCR (RG-3.08, verifie au
* ProviderRibProcessor : refus du DELETE du dernier RIB sous LCR ERP-135).
*
* Embarque sous `provider.ribs` UNIQUEMENT si l'user a accounting.view : le
* read-group est `provider:read:accounting`, retire du contexte par le
* ProviderProvider sinon (gating par omission de cle evite la fuite IBAN/BIC,
* piege n°4 du M1). Aucun #[AuditIgnore] sur iban/bic : l'audit etant admin-only,
* la tracabilite RIB est conservee (decision M1 reportee, § 2.7).
*
* Sous-ressource API (ERP-135, spec § 4.5) gating comptable renforce :
* - POST /api/providers/{providerId}/ribs : creation rattachee au prestataire
* parent (Link toProperty 'provider'), security technique.providers.accounting.manage.
* - PATCH / DELETE /api/provider_ribs/{id} : security technique.providers.accounting.manage.
* Le DELETE refuse la suppression du dernier RIB sous LCR (RG-3.08, 409).
* - GET /api/provider_ribs/{id} : lecture unitaire, security
* technique.providers.accounting.view (donnees bancaires sensibles). Pas de GET
* collection autonome.
* Tout passe par le ProviderRibProcessor (RG-3.08 sur DELETE).
*
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony (pas de controle
* banque reelle), avec controle croise pays BIC/IBAN (ibanPropertyPath). Audite
* (#[Auditable]) + Timestampable / Blamable.
*/
#[ApiResource(
operations: [
new Get(
security: "is_granted('technique.providers.accounting.view')",
normalizationContext: ['groups' => ['provider:read:accounting']],
),
new Post(
uriTemplate: '/providers/{providerId}/ribs',
uriVariables: [
'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
],
// read:false : pas de stade lecture du parent. Le Link toProperty
// resoudrait l'enfant (SELECT ProviderRib ... WHERE provider = :id) et
// casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
// manuellement par ProviderRibProcessor::linkParent (404 si absent).
read: false,
security: "is_granted('technique.providers.accounting.manage')",
normalizationContext: ['groups' => ['provider:read:accounting']],
denormalizationContext: ['groups' => ['provider:write:accounting']],
processor: ProviderRibProcessor::class,
),
new Patch(
security: "is_granted('technique.providers.accounting.manage')",
normalizationContext: ['groups' => ['provider:read:accounting']],
denormalizationContext: ['groups' => ['provider:write:accounting']],
processor: ProviderRibProcessor::class,
),
new Delete(
security: "is_granted('technique.providers.accounting.manage')",
processor: ProviderRibProcessor::class,
),
],
)]
#[ORM\Entity]
#[ORM\Table(name: 'provider_rib')]
#[ORM\Index(name: 'idx_provider_rib_provider', columns: ['provider_id'])]
#[Auditable]
class ProviderRib implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['provider:read:accounting'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Provider::class, inversedBy: 'ribs')]
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Provider $provider = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'Le libellé du RIB est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'Le libellé ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $label = null;
// Bic/Iban bornent deja le format (et donc la longueur) : pas de Length redondant
// calee sur la colonne (auto-exempte du miroir ERP-107). ibanPropertyPath :
// controle croise — le pays du BIC (positions 5-6) doit correspondre au pays de
// l'IBAN (positions 1-2). Violation portee sur `bic`.
#[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')]
#[Assert\Bic(
message: 'Le BIC n\'est pas valide.',
ibanPropertyPath: 'iban',
ibanMessage: 'Le BIC ne correspond pas au pays de l\'IBAN.',
)]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $bic = null;
#[ORM\Column(length: 34)]
#[Assert\NotBlank(message: 'L\'IBAN est obligatoire.', normalizer: 'trim')]
#[Assert\Iban(message: 'L\'IBAN n\'est pas valide.')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $iban = null;
// Ordre d'affichage du RIB (gere serveur, non expose au M3).
#[ORM\Column(options: ['default' => 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;
}
}
@@ -1,98 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Repository;
use App\Module\Technique\Domain\Entity\Provider;
use Doctrine\ORM\QueryBuilder;
interface ProviderRepositoryInterface
{
public function findById(int $id): ?Provider;
public function save(Provider $provider): void;
/**
* Restreint un QueryBuilder de liste aux prestataires rattaches au site donne
* (relation DIRECTE provider.sites). Sert le cloisonnement par site pilote par
* l'utilisateur (RG-3.17, § 2.13) : le ProviderProvider resout le site courant
* (CurrentSiteProvider) puis appelle cette methode quand l'user n'a pas
* `sites.bypass_scope`. Decouple ainsi la DECISION (Provider, qui connait
* l'user) du DQL (repository, qui ne connait que l'id de site).
*
* Sous-requete IN (et non JOIN sur la M2M) pour ne pas perturber le
* DISTINCT / ORDER BY / pagination du QueryBuilder de selection meme parti
* pris que les filtres ?categoryCode / ?siteId. Applique AVANT la pagination
* (le COUNT du Paginator reflete alors le perimetre de l'user).
*/
public function applySiteScope(QueryBuilder $qb, int $siteId): void;
/**
* Construit un QueryBuilder de liste pour le repertoire prestataires.
* - Exclut toujours les prestataires soft-deletes (deleted_at IS NOT NULL, RG-3.16).
* - Archivage (RG-3.16) :
* - $archivedOnly = true -> 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<string> $categoryCodes
* @param list<int> $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<Provider> $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<Provider> $providers
*/
public function hydrateContacts(array $providers): void;
}
@@ -1,76 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\Serializer;
use ApiPlatform\State\SerializerContextBuilderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\HttpFoundation\Request;
/**
* Decore le context builder de serialisation d'API Platform pour ajouter
* DYNAMIQUEMENT le groupe de lecture `provider:read:accounting` sur les
* ressources Provider, uniquement si l'utilisateur courant a la permission
* `technique.providers.accounting.view` (cf. spec-back M3 § 2.9 / § 4.1 /
* § 4.2). Jumeau de SupplierReadGroupContextBuilder (M2).
*
* Pourquoi un context builder et pas le Provider : un Provider retourne des
* donnees mais ne peut pas influencer les groupes de serialisation. Le contexte
* de normalisation est construit ici, en amont du serializer c'est le point
* d'extension idiomatique d'API Platform pour conditionner un groupe selon
* l'utilisateur. Realise l'intention « gating du groupe accounting » de la spec
* (le groupe n'est jamais pose par defaut sur l'operation : il est AJOUTE ici si
* la permission est presente resultat identique au « retrait » decrit en spec).
*
* S'applique aux operations de LECTURE (normalization) sur Provider : liste ET
* detail. Sans la permission, les champs comptables (siren, accountNumber,
* tvaMode, nTva, paymentDelay, paymentType, bank) ET les RIB embarques (groupe
* provider:read:accounting porte par getRibs()) ne sont jamais serialises la
* cle est totalement absente du JSON (gating par omission, parade bug #4 M1).
*
* Priorite de decoration -20 : on s'empile APRES les decorateurs Commercial
* (ClientReadGroupContextBuilder priorite 0, SupplierReadGroupContextBuilder
* priorite -10) sur le meme service `api_platform.serializer.context_builder`.
* Chaque decorateur passe la main pour toute ressource autre que la sienne :
* l'ordre de chainage n'a donc aucun effet fonctionnel, la priorite explicite ne
* sert qu'a lever l'ambiguite de plusieurs decorateurs sur un meme service.
*/
#[AsDecorator(decorates: 'api_platform.serializer.context_builder', priority: -20)]
final readonly class ProviderReadGroupContextBuilder implements SerializerContextBuilderInterface
{
public function __construct(
#[AutowireDecorated]
private SerializerContextBuilderInterface $decorated,
private Security $security,
) {}
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
// Uniquement en lecture, sur la ressource Provider, avec la permission.
if (!$normalization) {
return $context;
}
if (Provider::class !== ($context['resource_class'] ?? null)) {
return $context;
}
if (!$this->security->isGranted('technique.providers.accounting.view')) {
return $context;
}
$groups = $context['groups'] ?? [];
if (!in_array('provider:read:accounting', $groups, true)) {
$groups[] = 'provider:read:accounting';
}
$context['groups'] = $groups;
return $context;
}
}
@@ -1,212 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Core\Domain\Entity\User;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderAddress;
use App\Shared\Domain\Contract\SiteInterface;
use Doctrine\ORM\EntityManagerInterface;
use JsonException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture de la sous-ressource Adresse d'un prestataire (M3,
* spec-back § 4.5). Jumeau du SupplierAddressProcessor (M2), recentre sur le
* perimetre ERP-135, AVEC une garde supplementaire propre au M3 : le
* cloisonnement d'ECRITURE des sites de l'adresse (§ 2.13).
*
* Sequence :
* - POST / PATCH : rattachement au prestataire parent puis cloisonnement des
* 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).
* - DELETE : aucune regle metier specifique (suppression physique directe).
*
* La security de l'operation (technique.providers.manage) est appliquee par API
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut.
*
* @implements ProcessorInterface<ProviderAddress, null|ProviderAddress>
*/
final class ProviderAddressProcessor implements ProcessorInterface
{
private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope';
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof ProviderAddress) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
$this->guardSiteScope($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache l'adresse au prestataire parent de la sous-ressource POST
* (/providers/{providerId}/addresses) : la relation n'est pas peuplee
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
*/
private function linkParent(ProviderAddress $address, array $uriVariables): void
{
if (null !== $address->getProvider()) {
return;
}
$providerId = $uriVariables['providerId'] ?? null;
if (null === $providerId) {
return;
}
$provider = $providerId instanceof Provider
? $providerId
: $this->em->getRepository(Provider::class)->find($providerId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
// contrainte provider_id NOT NULL).
if (!$provider instanceof Provider) {
throw new NotFoundHttpException('Prestataire introuvable.');
}
$address->setProvider($provider);
}
/**
* RG-3.05 / § 2.13 (cloisonnement d'ECRITURE decision Matthieu 11/06) : un
* user SANS `sites.bypass_scope` ne peut attacher a CHAQUE adresse que des
* sites figurant dans ses propres `user_site`. Tout site hors perimetre -> 422
* sur `sites` (propertyPath consommable inline, convention ERP-101). Un user
* `bypass_scope` (Admin) peut attacher n'importe quel site. Miroir de
* ProviderProcessor::guardSiteScope, applique ici a la sous-ressource adresse.
*
* Ne joue que si `sites` est effectivement soumis : POST (entite non geree,
* sites obligatoires RG-3.05) ou PATCH portant la cle `sites`. Un PATCH qui ne
* touche pas aux sites n'est pas re-valide (les sites ont ete cloisonnes a leur
* pose). La validation porte sur l'ETAT RESULTANT (address.getSites()).
*/
private function guardSiteScope(ProviderAddress $address): void
{
if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) {
return;
}
// sites non soumis sur un PATCH : rien a cloisonner.
if ($this->em->contains($address) && !in_array('sites', $this->payloadKeys(), true)) {
return;
}
$allowedSiteIds = $this->currentUserSiteIds();
foreach ($address->getSites() as $site) {
if (!$site instanceof SiteInterface) {
continue;
}
if (!in_array($site->getId(), $allowedSiteIds, true)) {
$this->throwSitesViolation($address);
}
}
}
/**
* Identifiants des sites rattaches a l'utilisateur courant (`user_site`).
* Vide si pas d'user authentifie (cas defensif : la security d'operation
* garantit deja l'authentification).
*
* @return list<int>
*/
private function currentUserSiteIds(): array
{
$user = $this->security->getUser();
if (!$user instanceof User) {
return [];
}
$ids = [];
foreach ($user->getSites() as $site) {
if ($site instanceof SiteInterface && null !== $site->getId()) {
$ids[] = $site->getId();
}
}
return $ids;
}
/**
* Cles de premier niveau effectivement envoyees par le client (payload JSON
* brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies.
* Corps vide ou JSON invalide -> aucune cle.
*
* @return list<string>
*/
private function payloadKeys(): array
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return [];
}
$content = $request->getContent();
if ('' === $content) {
return [];
}
try {
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return [];
}
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
}
/**
* Leve une 422 portant une violation unique sur `sites` meme rendu Hydra que
* les contraintes Symfony, consommable inline par extractApiViolations (ERP-101).
*
* @return never
*/
private function throwSitesViolation(ProviderAddress $address): void
{
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Vous ne pouvez rattacher que des sites auxquels vous avez accès.',
null,
[],
$address,
'sites',
null,
));
throw new ValidationException($violations);
}
}
@@ -1,140 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderContact;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture de la sous-ressource Contact d'un prestataire (M3,
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
* perimetre ERP-135.
*
* Sequence :
* - POST / PATCH : rattachement au prestataire parent, normalisation serveur
* (RG-3.11 : prenom/nom Title Case, telephones reduits aux chiffres, email
* lowercase) via le ProviderFieldNormalizer partage, puis validation RG-3.04
* (au moins un champ parmi prenom / nom / telephone principal / email) avant
* persistance.
* - DELETE : aucune garde « dernier contact » au M3 la collection peut rester
* vide cote back (RG-3.12 front-driven, spec § 4.5). Suppression physique directe.
*
* La security de l'operation (technique.providers.manage) est appliquee par API
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut
* (Assert\Email, Assert\Length...).
*
* @implements ProcessorInterface<ProviderContact, null|ProviderContact>
*/
final class ProviderContactProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly ProviderFieldNormalizer $normalizer,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof ProviderContact) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
$this->normalize($data);
$this->validateName($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache le contact au prestataire parent de la sous-ressource POST
* (/providers/{providerId}/contacts). La relation n'est pas peuplee
* automatiquement par le Link sur une operation d'ecriture : on resout le
* parent depuis l'uri variable. Sur PATCH (entite existante), le prestataire
* est deja present -> no-op.
*/
private function linkParent(ProviderContact $contact, array $uriVariables): void
{
if (null !== $contact->getProvider()) {
return;
}
$providerId = $uriVariables['providerId'] ?? null;
if (null === $providerId) {
return;
}
$provider = $providerId instanceof Provider
? $providerId
: $this->em->getRepository(Provider::class)->find($providerId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
// contrainte provider_id NOT NULL).
if (!$provider instanceof Provider) {
throw new NotFoundHttpException('Prestataire introuvable.');
}
$contact->setProvider($provider);
}
/**
* Normalisation serveur (RG-3.11). Toutes les methodes du normalizer sont
* null-safe : une chaine vide apres trim devient null.
*/
private function normalize(ProviderContact $contact): void
{
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
$contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary()));
$contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary()));
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
}
/**
* RG-3.04 : un bloc Contact est valide des qu'au moins un champ parmi prenom /
* nom / telephone principal / email est renseigne (double garde avec le CHECK
* BDD chk_provider_contact_name leve une 422 propre rattachee au champ
* `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
* chaines vides (y compris un phone_secondary seul, hors CHECK) sont deja
* ramenees a null et ne suffisent pas a valider le bloc.
*/
private function validateName(ProviderContact $contact): void
{
if (null === $contact->getFirstName()
&& null === $contact->getLastName()
&& null === $contact->getPhonePrimary()
&& null === $contact->getEmail()) {
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Au moins un champ du contact est obligatoire (nom, prénom, téléphone ou email).',
null,
[],
$contact,
'firstName',
null,
));
throw new ValidationException($violations);
}
}
}
@@ -1,560 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Core\Domain\Entity\User;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Domain\Entity\Provider;
use App\Shared\Domain\Contract\SiteInterface;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\PersistentCollection;
use JsonException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture du repertoire prestataires (M3). Cf. spec-back M3 § 4.3 /
* § 4.4 + RG-3.10 / RG-3.13 / RG-3.14 / RG-3.15 / RG-3.17. Jumeau du
* SupplierProcessor (M2), avec deux differences structurantes (§ 3.1) :
* - PAS d'onglet Information (aucun champ description / competitors / ...) ni de
* validation de completude comptable -> le prestataire est minimal ;
* - le formulaire principal porte `sites` (M2M provider_site, RG-3.03), soumis au
* CLOISONNEMENT D'ECRITURE par site (RG-3.17, § 2.13) : un user sans
* `sites.bypass_scope` ne peut attacher que les sites de ses `user_site`.
*
* Sequence (POST / PATCH) :
* 1. Autorisation additionnelle par groupe d'onglet (mode strict RG-3.15). La
* security d'operation du PATCH est elargie a `manage` OU `accounting.manage`
* pour laisser entrer le role Compta ; ce processor re-gate alors finement :
* - champ comptable modifie dans le payload -> exige accounting.manage (403) ;
* - champ main (companyName / categories / sites) modifie -> exige manage
* (guardManage, 403) : empeche Compta d'editer un autre onglet ;
* - champ isArchived dans le payload -> exige archive (RG-3.13, 403) et
* interdit toute autre modification dans la meme requete (RG-3.13, 422).
* 2. Cloisonnement d'ECRITURE des sites (RG-3.17 / RG-3.03) : tout site attache
* hors des `user_site` de l'appelant non-bypass -> 422 sur `sites`.
* 3. Normalisation serveur (RG-3.11) via ProviderFieldNormalizer (companyName).
* 4. Pose / retrait de archivedAt (RG-3.13 true=now, RG-3.14 false=null).
* 5. Persistance via le persist_processor Doctrine, avec traduction des
* collisions d'unicite en 409 (RG-3.10 doublon de nom ; RG-3.14 conflit de
* restauration).
*
* Les RG inter-champs RG-3.07 (Virement -> banque), RG-3.08 (LCR -> >= 1 RIB) et
* RG-3.09 (categorie de type PRESTATAIRE) sont portees par des Assert\Callback +
* ->atPath() sur les entites Provider / ProviderAddress (jouees par API Platform
* AVANT ce processor), pour que chaque 422 porte un propertyPath consommable par
* extractApiViolations (mapping inline, pas un toast convention ERP-101). Le 409
* sur DELETE du dernier RIB en LCR (volet ecriture de RG-3.08) est porte par le
* ProviderRibProcessor (ERP-135).
*
* @implements ProcessorInterface<Provider, Provider>
*/
final class ProviderProcessor implements ProcessorInterface
{
/** Champs de l'onglet principal (groupe provider:write:main). */
private const array MAIN_FIELDS = [
'companyName', 'categories', 'sites',
];
/** Champs de l'onglet Comptabilite (groupe provider:write:accounting). */
private const array ACCOUNTING_FIELDS = [
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay',
'paymentType', 'bank',
];
/** Champ d'archivage (groupe provider:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived';
private const string PERM_MANAGE = 'technique.providers.manage';
private const string PERM_ACCOUNTING_MANAGE = 'technique.providers.accounting.manage';
private const string PERM_ARCHIVE = 'technique.providers.archive';
private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope';
/**
* Memoisation du dernier corps de requete decode, clos par le contenu brut
* (cf. SupplierProcessor) : payloadKeys() est appele plusieurs fois par requete,
* on evite de rejouer json_decode. Cle = contenu lui-meme, calcul pur -> aucune
* fuite entre requetes sur ce service partage.
*/
private ?string $decodedContent = null;
/** @var list<string> Cles de premier niveau correspondant au corps memoise. */
private array $decodedPayloadKeys = [];
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly ProviderFieldNormalizer $normalizer,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Provider) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// Reinitialisation de la memoisation du payload : le service est partage
// (stateful), on repart du corps de LA requete courante.
$this->decodedContent = null;
$this->decodedPayloadKeys = [];
$writableKeys = $this->writablePayloadKeys();
$isArchiveRequest = $this->guardArchive($data, $writableKeys);
$this->guardAccounting($data);
$this->guardSiteScope($data);
$this->normalize($data);
// guardManage apres normalize : la comparaison « change vs etat persiste »
// des champs texte (companyName) se fait sur des valeurs normalisees des
// deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
// Le seul index unique partiel est uq_provider_company_name_active
// (LOWER(company_name) parmi non-archives/non-deletes — § 2.6).
if ($isArchiveRequest && false === $data->isArchived()) {
// RG-3.14 : restauration en conflit avec un homonyme actif.
throw new ConflictHttpException(
'Restauration impossible : un autre prestataire a pris le nom entre-temps.',
$e,
);
}
// RG-3.10 : doublon de nom de societe.
throw new ConflictHttpException(
sprintf('Un prestataire nommé "%s" existe déjà.', (string) $data->getCompanyName()),
$e,
);
}
}
/**
* RG-3.13 / RG-3.14 : si le payload bascule reellement isArchived, exige la
* permission archive (403), interdit toute autre modification (422) et
* pose/retire archivedAt. Retourne true si la requete est une requete
* d'archivage. Restreint a la mise a jour d'un prestataire existant ET au seul
* cas ou isArchived change vraiment (cf. SupplierProcessor).
*
* @param list<string> $writableKeys cles ecrivables du payload (hors @* et champs inconnus)
*/
private function guardArchive(Provider $data, array $writableKeys): bool
{
// POST / entite non geree : l'archivage est une action de mise a jour.
if (!$this->em->contains($data)) {
return false;
}
// isArchived inchange par rapport a l'etat persiste : pas une requete
// d'archivage (cas du PATCH representation complete).
if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) {
return false;
}
if (!$this->security->isGranted(self::PERM_ARCHIVE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
self::ARCHIVE_FIELD,
self::PERM_ARCHIVE,
));
}
// RG-3.13 : une requete d'archivage ne modifie aucun autre champ ecrivable.
if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) {
throw new UnprocessableEntityHttpException(
'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".',
);
}
// RG-3.13 (true -> now) / RG-3.14 (false -> null).
$data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);
return true;
}
/**
* RG-3.15 : la modification effective d'un champ comptable exige
* accounting.manage, sinon 403 sur l'ensemble du payload (mode strict, pas de
* filtrage silencieux). On ne gate que si un champ change reellement par
* rapport a l'etat persiste (POST/PATCH renvoyant des champs comptables
* inchanges ne declenche pas de 403 parasite). Le message precise le premier
* champ fautif.
*/
private function guardAccounting(Provider $data): void
{
$changed = $this->changedAccountingFields($data);
if ([] === $changed) {
return;
}
if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
$changed[0],
self::PERM_ACCOUNTING_MANAGE,
));
}
}
/**
* § 2.9 / RG-3.15 : la modification effective d'un champ « metier » (onglet
* principal : companyName / categories / sites) exige
* `technique.providers.manage`. Sans cette permission -> 403 sur l'ensemble du
* payload (mode strict, miroir de guardAccounting). C'est ce qui empeche le
* role Compta qui entre dans le PATCH via `accounting.manage` (security
* d'operation elargie) — d'editer autre chose que l'onglet Comptabilite.
*
* Ne s'applique qu'aux mises a jour (entite geree) : la creation (POST) est
* deja gardee par la security d'operation `manage`.
*/
private function guardManage(Provider $data): void
{
if (!$this->em->contains($data)) {
return;
}
$changed = $this->changedBusinessFields($data);
if ([] === $changed) {
return;
}
if (!$this->security->isGranted(self::PERM_MANAGE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
$changed[0],
self::PERM_MANAGE,
));
}
}
/**
* RG-3.17 / RG-3.03 (cloisonnement d'ECRITURE § 2.13) : un user SANS
* `sites.bypass_scope` ne peut attacher au prestataire que des sites figurant
* dans ses propres `user_site`. Tout site hors perimetre -> 422 sur `sites`
* (propertyPath consommable inline, convention ERP-101). Un user `bypass_scope`
* (Admin auto) peut attacher n'importe quel site.
*
* Interaction avec SiteCollectionScopedExtension (module Sites) : pour un user
* sans `sites.bypass_scope` NI `sites.read_ref`, la resolution de l'IRI de site
* hors perimetre echoue DEJA en amont (item Site « introuvable » -> 400
* anti-enumeration), avant ce processor. Cette garde reste donc l'enforcement
* AUTORITAIRE de RG-3.17 pour le cas particulier d'un user `sites.read_ref`
* (qui peut resoudre N'IMPORTE quel site comme referentiel transverse mais ne
* doit rattacher que ses propres sites), et une defense en profondeur sinon.
*
* Ne joue que si `sites` est effectivement soumis : POST (entite non geree,
* sites obligatoires RG-3.03) ou PATCH portant la cle `sites`. Un PATCH qui ne
* touche pas aux sites n'est pas re-valide (les sites ont ete cloisonnes a leur
* pose). La validation porte sur l'ETAT RESULTANT (data.getSites()).
*/
private function guardSiteScope(Provider $data): void
{
if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) {
return;
}
// sites non soumis sur un PATCH : rien a cloisonner.
if ($this->em->contains($data) && !in_array('sites', $this->payloadKeys(), true)) {
return;
}
$allowedSiteIds = $this->currentUserSiteIds();
foreach ($data->getSites() as $site) {
if (!$site instanceof SiteInterface) {
continue;
}
if (!in_array($site->getId(), $allowedSiteIds, true)) {
$this->throwSitesViolation($data);
}
}
}
/**
* Identifiants des sites rattaches a l'utilisateur courant (`user_site`).
* Vide si pas d'user authentifie (cas defensif : la security d'operation
* garantit deja l'authentification).
*
* @return list<int>
*/
private function currentUserSiteIds(): array
{
$user = $this->security->getUser();
if (!$user instanceof User) {
return [];
}
$ids = [];
foreach ($user->getSites() as $site) {
if ($site instanceof SiteInterface && null !== $site->getId()) {
$ids[] = $site->getId();
}
}
return $ids;
}
/**
* Champs « metier » (onglet principal : companyName / categories / sites) dont
* la valeur courante differe de l'etat persiste. Scalaires compares par valeur ;
* collections M2M (categories / sites) comparees par ensemble d'identifiants
* (cf. collectionChanged) la simple presence dans le payload ne suffit pas,
* sous peine de 403 parasite sur un PATCH representation complete.
*
* @return list<string>
*/
private function changedBusinessFields(Provider $data): array
{
$changed = [];
if ($this->fieldChanged($data, 'companyName', $data->getCompanyName())) {
$changed[] = 'companyName';
}
if ($this->collectionChanged($data, 'categories', $data->getCategories()->toArray())) {
$changed[] = 'categories';
}
if ($this->collectionChanged($data, 'sites', $data->getSites()->toArray())) {
$changed[] = 'sites';
}
return $changed;
}
/**
* Vrai si une collection M2M (`categories` ou `sites`) differe reellement de
* l'etat persiste. Ces collections ne sont pas tracees par
* getOriginalEntityData : on compare par identifiants (independamment de
* l'ordre) le snapshot de la PersistentCollection (etat charge) a l'etat
* courant (apres application du payload). Symetrique des scalaires : seul un
* changement effectif compte, pas la simple presence dans le payload.
*
* - POST / entite non geree : fournir la collection est un acte metier
* (branche defensive, guardManage ne s'execute que sur entite geree).
* - cle absente du payload (PATCH partiel) : aucun changement.
*
* @param array<int, object> $current
*/
private function collectionChanged(Provider $data, string $field, array $current): bool
{
if (!$this->em->contains($data)) {
return true;
}
if (!in_array($field, $this->payloadKeys(), true)) {
return false;
}
$collection = 'categories' === $field ? $data->getCategories() : $data->getSites();
// Hors PersistentCollection (cas limite hors flux PATCH reel) : faute d'etat
// persiste comparable, on se rabat sur la presence payload.
if (!$collection instanceof PersistentCollection) {
return true;
}
return $this->idSet($current) !== $this->idSet($collection->getSnapshot());
}
/**
* Ensemble trie des identifiants d'une liste d'entites pour une comparaison
* par valeur independante de l'ordre.
*
* @param array<int, object> $entities
*
* @return list<mixed>
*/
private function idSet(array $entities): array
{
$ids = array_map(
static fn (object $entity): mixed => method_exists($entity, 'getId')
? $entity->getId()
: spl_object_id($entity),
array_values($entities),
);
sort($ids);
return $ids;
}
/**
* Champs comptables dont la valeur courante differe de l'etat persiste. Les
* relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par
* identite d'objet : l'identity map Doctrine renvoie la meme instance tant que
* la reference est inchangee.
*
* @return list<string>
*/
private function changedAccountingFields(Provider $data): array
{
$changed = [];
foreach (self::ACCOUNTING_FIELDS as $field) {
$newValue = match ($field) {
'siren' => $data->getSiren(),
'accountNumber' => $data->getAccountNumber(),
'tvaMode' => $data->getTvaMode(),
'nTva' => $data->getNTva(),
'paymentDelay' => $data->getPaymentDelay(),
'paymentType' => $data->getPaymentType(),
'bank' => $data->getBank(),
};
if ($this->fieldChanged($data, $field, $newValue)) {
$changed[] = $field;
}
}
return $changed;
}
/**
* Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une
* entite non geree (creation/POST), l'etat persiste est vide : toute valeur
* non-null est alors un changement.
*/
private function fieldChanged(Provider $data, string $field, mixed $newValue): bool
{
$original = $this->originalData($data);
return $newValue !== ($original[$field] ?? null);
}
/**
* Snapshot des valeurs persistees de l'entite (telles que chargees, avant
* application du payload). Vide pour une entite non geree (POST).
*
* @return array<string, mixed>
*/
private function originalData(Provider $data): array
{
if (!$this->em->contains($data)) {
return [];
}
return $this->em->getUnitOfWork()->getOriginalEntityData($data);
}
/**
* Normalisation serveur du formulaire principal (RG-3.11). Seul companyName est
* porte par le Provider (les champs de contact sont normalises par le processor
* de sous-ressource ProviderContact, ticket dedie). Le setter non-nullable n'est
* touche que si une valeur est presente, pour ne jamais ecraser l'existant lors
* d'un PATCH partiel.
*/
private function normalize(Provider $data): void
{
if (null !== $data->getCompanyName()) {
$data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName()));
}
}
/**
* Cles ecrivables effectivement presentes dans le payload : on retire les cles
* JSON-LD (@id, @context...) et tout champ non rattache a un groupe d'ecriture
* connu. Base du 422 d'archivage (RG-3.13).
*
* @return list<string>
*/
private function writablePayloadKeys(): array
{
$writable = array_merge(
self::MAIN_FIELDS,
self::ACCOUNTING_FIELDS,
[self::ARCHIVE_FIELD],
);
return array_values(array_intersect($this->payloadKeys(), $writable));
}
/**
* Cles de premier niveau effectivement envoyees par le client (payload JSON
* brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies.
*
* @return list<string>
*/
private function payloadKeys(): array
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return [];
}
$content = $request->getContent();
// Cache hit : meme corps brut que le dernier decodage -> memes cles.
if ($content === $this->decodedContent) {
return $this->decodedPayloadKeys;
}
$this->decodedContent = $content;
$this->decodedPayloadKeys = $this->extractPayloadKeys($content);
return $this->decodedPayloadKeys;
}
/**
* Decode le corps brut et en extrait les cles de premier niveau (chaines).
* Corps vide ou JSON invalide -> aucune cle.
*
* @return list<string>
*/
private function extractPayloadKeys(string $content): array
{
if ('' === $content) {
return [];
}
try {
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return [];
}
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
}
/**
* Leve une 422 portant une violation unique sur `sites` meme rendu Hydra que
* les contraintes Symfony, consommable inline par extractApiViolations (ERP-101).
*
* @return never
*/
private function throwSitesViolation(Provider $root): void
{
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Vous ne pouvez rattacher que des sites auxquels vous avez accès.',
null,
[],
$root,
'sites',
null,
));
throw new ValidationException($violations);
}
}
@@ -1,113 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderRib;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Processor d'ecriture de la sous-ressource RIB d'un prestataire (M3, spec-back
* § 4.5). Jumeau du SupplierRibProcessor (M2), recentre sur le perimetre ERP-135.
*
* Sequence :
* - POST / PATCH : rattachement au prestataire parent. Aucune normalisation
* specifique ; la validite de l'IBAN et du BIC est garantie par Assert\Iban /
* Assert\Bic sur l'entite (jouees en amont par API Platform). Aucun
* #[AuditIgnore] sur iban/bic : la tracabilite comptable est volontaire
* (decision M1/M2 reportee, spec § 2.7).
* - DELETE : RG-3.08 si le prestataire est en reglement LCR, la suppression de
* son DERNIER RIB est refusee (409), car LCR exige au moins un RIB.
*
* La security de l'operation (technique.providers.accounting.manage) est appliquee
* par API Platform en amont : un utilisateur sans cette permission recoit 403 sur
* POST/PATCH/DELETE avant d'atteindre ce processor — c'est le niveau de gating
* renforce des donnees bancaires (distinct de manage, spec § 4.5).
*
* @implements ProcessorInterface<ProviderRib, null|ProviderRib>
*/
final class ProviderRibProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof ProviderRib) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
$this->guardLastRibDeletionUnderLcr($data);
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache le RIB au prestataire parent de la sous-ressource POST
* (/providers/{providerId}/ribs) : la relation n'est pas peuplee
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
*/
private function linkParent(ProviderRib $rib, array $uriVariables): void
{
if (null !== $rib->getProvider()) {
return;
}
$providerId = $uriVariables['providerId'] ?? null;
if (null === $providerId) {
return;
}
$provider = $providerId instanceof Provider
? $providerId
: $this->em->getRepository(Provider::class)->find($providerId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
// contrainte provider_id NOT NULL).
if (!$provider instanceof Provider) {
throw new NotFoundHttpException('Prestataire introuvable.');
}
$rib->setProvider($provider);
}
/**
* RG-3.08 : un prestataire dont le type de reglement est LCR doit conserver au
* moins un RIB. La collection inclut le RIB en cours de suppression : un
* effectif <= 1 signifie qu'il ne resterait aucun RIB -> 409. Pour tout autre
* type de reglement, les RIBs sont optionnels (suppression libre).
*/
private function guardLastRibDeletionUnderLcr(ProviderRib $rib): void
{
$provider = $rib->getProvider();
if (null === $provider) {
return;
}
if ('LCR' === $provider->getPaymentType()?->getCode() && $provider->getRibs()->count() <= 1) {
throw new ConflictHttpException(
'Impossible de supprimer le dernier RIB : le type de règlement LCR exige au moins un RIB.',
);
}
}
}
@@ -1,256 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider du repertoire prestataires (M3). Cf. spec-back M3 § 4.1 / § 4.2 +
* RG-3.16 / RG-3.17. Jumeau du SupplierProvider (M2), augmente du cloisonnement
* par site pilote par l'utilisateur (§ 2.13).
*
* Collection (GET /api/providers) :
* - exclut par defaut les archives (is_archived = true) ET les soft-deletes
* (deleted_at IS NOT NULL) RG-3.16 ;
* - ?includeArchived=true reintegre les archives (les soft-deletes restent
* exclus au M3) RG-3.16 ;
* - tri par defaut companyName ASC RG-3.16 ;
* - filtres ?search=... (fuzzy companyName + contacts lies : firstName /
* lastName / email), ?categoryCode=<code> (prestataires ayant >= 1 categorie
* de ce code, repetable) et ?siteId=<id> (prestataires rattaches a ce site
* via la relation DIRECTE provider.sites, repetable) ;
* - pagination obligatoire (regle ABSOLUE n°13) : Paginator ORM ; echappatoire
* ?pagination=false pour alimenter un <select> sans pagination.
*
* Cloisonnement par site (RG-3.17, § 2.13) applique ICI (le QueryBuilder du
* repository ne connait pas l'user courant) :
* - si l'user N'A PAS `sites.bypass_scope` ET que CurrentSiteProvider::get()
* retourne un site -> la liste est restreinte aux prestataires dont
* provider.sites contient le currentSite (repository::applySiteScope), AVANT
* pagination : totalItems reflete le perimetre de l'user ;
* - le DETAIL (Get / provider de PATCH) d'un prestataire hors perimetre renvoie
* 404 (null) ne pas reveler l'existence d'une ligne hors site ;
* - user `bypass_scope` (Admin auto, profils consolidation) -> aucun filtre ;
* - currentSite = null (module Sites off / user sans site) -> no-op lecture
* (aligne site-aware.md § 5).
*
* Item (GET /api/providers/{id} + provider de PATCH) :
* - 404 si introuvable OU soft-delete (deleted_at non null, jamais expose au
* M3) ; les archives restent consultables/restaurables en detail ;
* - 404 si hors perimetre site (cloisonnement, cf. ci-dessus).
*
* Le filtrage des champs comptables en lecture (groupe provider:read:accounting)
* n'est PAS fait ici mais dans ProviderReadGroupContextBuilder : un Provider
* retourne des donnees mais ne peut pas influencer les groupes de serialisation.
*
* @implements ProviderInterface<Provider>
*/
final class ProviderProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
private readonly ProviderRepositoryInterface $repository,
private readonly Pagination $pagination,
private readonly Security $security,
// Outillage site-aware sanctionne (site-aware.md § 6.2 : « injecter
// CurrentSiteProvider dans le service et ajouter la clause WHERE
// manuellement » pour les cas multi-site non couverts par
// SiteScopedQueryExtension). Type-hint sur l'interface pour le mock test.
private readonly CurrentSiteProviderInterface $currentSiteProvider,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Provider|null
{
if ($operation instanceof CollectionOperationInterface) {
return $this->provideCollection($operation, $context);
}
return $this->provideItem($uriVariables);
}
/**
* @param array<string, mixed> $context
*
* @return list<Provider>|Paginator<Provider>
*/
private function provideCollection(Operation $operation, array $context): array|Paginator
{
$filters = $context['filters'] ?? [];
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
$archivedOnly = $this->readBool($filters['archivedOnly'] ?? false);
$search = $filters['search'] ?? null;
// categoryCode accepte un code unique (?categoryCode=NETTOYAGE, selects)
// OU une liste (?categoryCode[]=A&categoryCode[]=B, drawer multi).
$categoryCodes = $this->readStringList($filters['categoryCode'] ?? []);
$siteIds = $this->readIntList($filters['siteId'] ?? []);
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
$qb = $this->repository->createListQueryBuilder(
$includeArchived,
is_string($search) ? $search : null,
$categoryCodes,
$siteIds,
$archivedOnly,
);
// Cloisonnement par site (RG-3.17) AVANT pagination : ajoute une clause
// restreignant au currentSite pour un user non-bypass. S'intersecte avec
// un eventuel filtre ?siteId du client (deux sous-requetes ANDees).
$scopeSite = $this->siteScopeOrNull();
if (null !== $scopeSite) {
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
}
// Echappatoire ?pagination=false : collection complete sans Paginator
// (regle n°13 — utile pour un <select> cote front).
if (!$this->pagination->isEnabled($operation, $context)) {
/** @var list<Provider> $providers */
$providers = $qb->getQuery()->getResult();
// Hydratation batchee des collections affichees (§ 2.12) : evite le
// N+1 si la serialisation touche categories/sites, sans cartesien.
$this->repository->hydrateListCollections($providers);
return $providers;
}
$limit = $this->pagination->getLimit($operation, $context);
$page = max(1, $this->pagination->getPage($context));
$offset = ($page - 1) * $limit;
$qb->setFirstResult($offset)->setMaxResults($limit);
// Le QB de selection ne porte pas de fetch-join to-many (§ 2.12) : le
// COUNT est simple, fetchJoinCollection inutile. On materialise la page
// puis on hydrate ses collections en lot (memes entites managees).
$paginator = new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
$this->repository->hydrateListCollections(iterator_to_array($paginator));
return $paginator;
}
/**
* @param array<string, mixed> $uriVariables
*/
private function provideItem(array $uriVariables): ?Provider
{
$id = $uriVariables['id'] ?? null;
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$provider = $this->repository->findById((int) $id);
if (null === $provider) {
return null;
}
// Soft-delete : jamais expose au M3 (HP-M4) — 404 via retour null.
// Les archives restent visibles en detail (consultation + restauration).
if (null !== $provider->getDeletedAt()) {
return null;
}
// Cloisonnement par site (RG-3.17) : un prestataire hors du perimetre de
// l'user -> 404 (ne pas reveler son existence). No-op pour bypass_scope ou
// currentSite null.
$scopeSite = $this->siteScopeOrNull();
if (null !== $scopeSite && !$this->providerHasSite($provider, (int) $scopeSite->getId())) {
return null;
}
return $provider;
}
/**
* Site de cloisonnement a appliquer en LECTURE, ou null si aucun cloisonnement
* (user `sites.bypass_scope`, ou pas de site courant resolu module Sites off
* / user sans currentSite, aligne site-aware.md § 5).
*/
private function siteScopeOrNull(): ?SiteInterface
{
if ($this->security->isGranted('sites.bypass_scope')) {
return null;
}
return $this->currentSiteProvider->get();
}
/**
* Vrai si le prestataire est rattache (relation directe provider.sites) au
* site d'id donne. Comparaison en memoire sur l'entite deja chargee (detail).
*/
private function providerHasSite(Provider $provider, int $siteId): bool
{
foreach ($provider->getSites() as $site) {
if ($site instanceof SiteInterface && $site->getId() === $siteId) {
return true;
}
}
return false;
}
/**
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
*/
private function readBool(mixed $raw): bool
{
if (is_bool($raw)) {
return $raw;
}
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
}
/**
* Normalise un filtre en liste de chaines. Tolere un code unique (string)
* ou une liste (?key[]=a&key[]=b). Trim + retrait des vides.
*
* @return list<string>
*/
private function readStringList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if (is_string($value) && '' !== trim($value)) {
$out[] = trim($value);
}
}
return $out;
}
/**
* Normalise un filtre en liste d'identifiants entiers positifs. Tolere une
* valeur unique ou une liste (?key[]=1&key[]=2).
*
* @return list<int>
*/
private function readIntList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
$out[] = (int) $value;
}
}
return $out;
}
}
@@ -1,328 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\Controller;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderContact;
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderProvider;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Export XLSX du repertoire prestataires (M3, spec-back § 4.6). Jumeau du
* `SupplierExportController` (M2, module Commercial), augmente du cloisonnement
* par site pilote par l'utilisateur (§ 2.13).
*
* Controller Symfony custom (et non operation API Platform) car il produit un
* binaire de fichier, pas une representation Hydra. `priority: 1` est
* OBLIGATOIRE sur la route : sans cela API Platform capterait
* `/api/providers/export.xlsx` comme l'item `GET /api/providers/{id}.{_format}`
* (id="export", _format="xlsx") cf. CLAUDE.md « controller custom sous /api ».
*
* Separation des responsabilites :
* - le COMMENT (generation du fichier) est delegue au service Shared
* {@see SpreadsheetExporterInterface} generique, reutilisable, sans metier ;
* - le QUOI vit ICI : selection des prestataires (memes filtres que
* `GET /api/providers`, via {@see ProviderRepositoryInterface::createListQueryBuilder()}),
* cloisonnement par site, et mapping metier des colonnes.
*
* Cloisonnement par site (RG-3.17, § 2.13) : replique la logique du
* {@see ProviderProvider}
* un user sans `sites.bypass_scope` et possedant un currentSite n'exporte que
* les prestataires rattaches a ce site (relation DIRECTE provider.sites). Le
* QueryBuilder ne connait pas l'user : la decision est prise ICI, le DQL dans le
* repository (applySiteScope).
*
* Colonnes de contact : alimentees par le CONTACT PRINCIPAL du prestataire le
* ProviderContact de plus petit `position` (decision D2, spec § 4.6).
*
* La colonne SIREN n'est ajoutee que si l'utilisateur a la permission
* `technique.providers.accounting.view` (gating identique a la lecture, § 2.9).
*/
#[AsController]
final class ProviderExportController
{
public function __construct(
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
private readonly ProviderRepositoryInterface $repository,
private readonly SpreadsheetExporterInterface $exporter,
private readonly Security $security,
// Outillage site-aware (cf. ProviderProvider) : resout le site courant pour
// appliquer le cloisonnement RG-3.17 a l'export comme a la liste.
private readonly CurrentSiteProviderInterface $currentSiteProvider,
) {}
#[Route('/api/providers/export.xlsx', name: 'technique_providers_export_xlsx', methods: ['GET'], priority: 1)]
#[IsGranted('technique.providers.view')]
public function __invoke(Request $request): Response
{
// Memes filtres d'archivage que la vue liste (ProviderProvider) pour que
// l'export reflete exactement ce que l'utilisateur voit a l'ecran :
// - includeArchived : inclut les archives en plus des actifs ;
// - archivedOnly : restreint aux seules archives (prioritaire, cf.
// createListQueryBuilder).
$includeArchived = $this->readBool($request->query->get('includeArchived'));
$archivedOnly = $this->readBool($request->query->get('archivedOnly'));
$search = $request->query->getString('search') ?: null;
// Memes filtres que la vue liste : categoryCode/siteId tolerent une valeur
// unique ou une liste (?categoryCode[]=A&siteId[]=1). On lit via all() pour
// ne pas lever d'exception sur une valeur scalaire.
$query = $request->query->all();
$categoryCodes = $this->readStringList($query['categoryCode'] ?? []);
$siteIds = $this->readIntList($query['siteId'] ?? []);
$qb = $this->repository
->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly)
;
// Cloisonnement par site (RG-3.17, § 2.13) AVANT materialisation : restreint
// au currentSite pour un user non-bypass (s'intersecte avec un eventuel
// ?siteId du client). No-op pour bypass_scope ou currentSite null.
$scopeSite = $this->siteScopeOrNull();
if (null !== $scopeSite) {
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
}
/** @var list<Provider> $providers */
$providers = $qb->getQuery()->getResult();
// Hydratation batchee des collections affichees (§ 2.12) : le QB de
// selection ne fetch-join pas les to-many. On remplit categories + sites en
// lot (colonnes « Catégories » / « Sites »), puis les contacts (colonnes du
// contact principal) — chacune en requetes IN bornees, anti N+1.
$this->repository->hydrateListCollections($providers);
$this->repository->hydrateContacts($providers);
$withSiren = $this->security->isGranted('technique.providers.accounting.view');
$binary = $this->exporter->export(
'Répertoire prestataires',
$this->buildHeaders($withSiren),
$this->buildRows($providers, $withSiren),
);
return $this->buildResponse($binary);
}
/**
* Site de cloisonnement a appliquer en LECTURE, ou null si aucun cloisonnement
* (user `sites.bypass_scope`, ou pas de site courant resolu module Sites off
* / user sans currentSite). Miroir de ProviderProvider::siteScopeOrNull().
*/
private function siteScopeOrNull(): ?SiteInterface
{
if ($this->security->isGranted('sites.bypass_scope')) {
return null;
}
return $this->currentSiteProvider->get();
}
/**
* Colonnes de l'export (spec § 4.6). SIREN inseree avant la date de creation,
* uniquement si l'utilisateur a accounting.view.
*
* @return list<string>
*/
private function buildHeaders(bool $withSiren): array
{
$headers = [
'Nom prestataire',
'Contact principal',
'Téléphone principal',
'Téléphone secondaire',
'Email',
'Catégories',
'Sites',
];
if ($withSiren) {
$headers[] = 'SIREN';
}
$headers[] = 'Date de création';
return $headers;
}
/**
* @param list<Provider> $providers
*
* @return iterable<list<null|scalar>>
*/
private function buildRows(array $providers, bool $withSiren): iterable
{
foreach ($providers as $provider) {
$contact = $this->principalContact($provider);
$row = [
$provider->getCompanyName(),
null !== $contact ? $this->formatContactName($contact) : '',
$contact?->getPhonePrimary() ?? '',
$contact?->getPhoneSecondary() ?? '',
$contact?->getEmail() ?? '',
$this->formatCategories($provider),
$this->formatSites($provider),
];
if ($withSiren) {
$row[] = $provider->getSiren();
}
$row[] = $provider->getCreatedAt()?->format('d/m/Y');
yield $row;
}
}
/**
* Contact principal du prestataire : le ProviderContact de plus petit
* `position` (decision D2, spec § 4.6). Null si le prestataire n'a aucun
* contact (les colonnes contact restent vides).
*/
private function principalContact(Provider $provider): ?ProviderContact
{
$contacts = $provider->getContacts()->toArray();
if ([] === $contacts) {
return null;
}
usort(
$contacts,
static fn (ProviderContact $a, ProviderContact $b): int => $a->getPosition() <=> $b->getPosition(),
);
return $contacts[0];
}
/**
* Libelle du contact principal « Nom Prénom » (spec § 4.6). Les deux parties
* sont optionnelles (RG-3.04 : au moins l'une des deux), d'ou le trim final.
*/
private function formatContactName(ProviderContact $contact): string
{
return trim(sprintf('%s %s', $contact->getLastName() ?? '', $contact->getFirstName() ?? ''));
}
/**
* Libelles des categories du prestataire, dedupliques, tries, joints par
* virgule.
*/
private function formatCategories(Provider $provider): string
{
$names = [];
foreach ($provider->getCategories() as $category) {
// @var CategoryInterface $category
$name = $category->getName();
if (null !== $name && '' !== $name) {
$names[$name] = true;
}
}
return $this->joinSorted($names);
}
/**
* Sites du prestataire (relation DIRECTE provider.sites, RG-3.03 contrairement
* au fournisseur M2 dont les sites sont portes par les adresses). La colonne
* « Sites » agrege l'union distincte des sites rattaches.
*/
private function formatSites(Provider $provider): string
{
$names = [];
foreach ($provider->getSites() as $site) {
// @var SiteInterface $site
$name = $site->getName();
if (null !== $name && '' !== $name) {
$names[$name] = true;
}
}
return $this->joinSorted($names);
}
/**
* @param array<string, true> $names ensemble de libelles (cles)
*/
private function joinSorted(array $names): string
{
$list = array_keys($names);
sort($list);
return implode(', ', $list);
}
private function buildResponse(string $binary): Response
{
$filename = sprintf('repertoire-prestataires-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
$response = new Response($binary);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
return $response;
}
/**
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
* Aligne sur ProviderProvider pour un comportement identique a la liste.
*/
private function readBool(mixed $raw): bool
{
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
}
/**
* Normalise un filtre en liste de chaines (valeur unique ou liste).
* Aligne sur ProviderProvider pour un comportement identique a la liste.
*
* @return list<string>
*/
private function readStringList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if (is_string($value) && '' !== trim($value)) {
$out[] = trim($value);
}
}
return $out;
}
/**
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
* ou liste). Aligne sur ProviderProvider.
*
* @return list<int>
*/
private function readIntList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
$out[] = (int) $value;
}
}
return $out;
}
}
@@ -1,287 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\Doctrine;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Provider>
*/
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 applySiteScope(QueryBuilder $qb, int $siteId): void
{
// Cloisonnement par site (RG-3.17, § 2.13) : ne garder que les prestataires
// dont provider.sites contient le site donne. Sous-requete IN (alias p5
// distinct des filtres p2/p3/p4) pour ne pas perturber le tri/pagination du
// QueryBuilder principal — meme parti pris que applyCategoryCodes / applySiteIds.
// Parametre :scopeSiteId distinct de :siteIds (filtre ?siteId du client) pour
// que les deux clauses puissent coexister (intersection) sans collision.
$sub = $this->getEntityManager()->createQueryBuilder()
->select('p5.id')
->from(Provider::class, 'p5')
->join('p5.sites', 'site5')
->where('site5.id = :scopeSiteId')
;
$qb->andWhere($qb->expr()->in('p.id', $sub->getDQL()))
->setParameter('scopeSiteId', $siteId)
;
}
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<string> $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<int> $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<Provider> $providers
*
* @return list<int>
*/
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<mixed> $values
*
* @return list<string>
*/
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<mixed> $values
*
* @return list<int>
*/
private function normalizeIntList(array $values): array
{
$out = [];
foreach ($values as $value) {
if (is_numeric($value) && (int) $value > 0) {
$out[] = (int) $value;
}
}
return $out;
}
}
-58
View File
@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique;
/**
* Module Technique (M3) pole distinct du Commercial qui porte le repertoire
* prestataires (entites Provider* livrees par les tickets suivants du M3).
*
* Decision Matthieu (11/06/2026) : le repertoire prestataires vit dans un
* module a part entiere « Technique » (et non sous Commercial), conformement au
* docx source. Ce module est activable/desactivable comme les autres
* (cf. config/modules.php), non requis au boot.
*
* Au ticket 1.1, le module ne porte encore aucune entite : il declare seulement
* son identite et son jeu de permissions (cf. spec-back M3 § 2.1 + § 5.1). Le
* cablage de la section sidebar « Technique » et l'attribution des permissions
* aux roles interviennent avec l'ecran prestataires (tickets ulterieurs).
*/
final class TechniqueModule
{
public const string ID = 'technique';
public const string LABEL = 'Technique';
public const bool REQUIRED = false;
/**
* Liste declarative des permissions RBAC exposees par le module Technique.
*
* Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand)
* qui se charge d'upserter ces entrees dans la table `permission`, de
* reactiver les codes precedemment marques orphelins et de marquer comme
* orphelins ceux qui ont disparu du code source.
*
* La cle `module` est auto-injectee par le sync command a partir de
* `self::ID`, il est donc inutile de la repeter dans chaque entree.
*
* Convention de nommage des codes : `module.resource[.sub].action` en
* snake_case, le prefixe module devant correspondre exactement a
* `self::ID` (verifie par la commande de synchronisation).
*
* Granularite alignee sur Commercial (les prestataires sont le jumeau des
* fournisseurs) : view + manage, plus deux permissions dediees a l'onglet
* Comptabilite et une a l'archivage (cf. spec-back M3 § 2.9 + § 5.1).
*
* @return array<int, array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'technique.providers.view', 'label' => 'Voir les prestataires'],
['code' => 'technique.providers.manage', 'label' => 'Créer / modifier les prestataires (hors onglet Comptabilité)'],
['code' => 'technique.providers.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un prestataire'],
['code' => 'technique.providers.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un prestataire'],
['code' => 'technique.providers.archive', 'label' => 'Archiver / restaurer un prestataire'],
];
}
}
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Application\Idtf;
use RuntimeException;
use function array_slice;
/**
* Parsing pur d'une matrice (lignes/colonnes 0-indexees, telle que retournee
* par PhpSpreadsheet::toArray) de l'export Excel IDTF vers des lignes
* normalisees pretes a l'upsert. Sans dependance a PhpSpreadsheet : la matrice
* est un simple tableau, ce qui rend le parsing testable en isolation.
*
* Robuste au reordonnancement des colonnes (mapping par libelle normalise) et
* aux lignes de preambule (detection dynamique de la ligne d'en-tete). Voir
* ERP-149 § 2.
*/
final class IdtfSheetParser
{
/**
* @param array<int, array<int, mixed>> $matrix
*
* @return array{exportDate: null|string, rows: list<array<string, mixed>>}
*/
public static function parse(array $matrix): array
{
$exportDate = self::extractExportDate($matrix);
$headerIndex = self::findHeaderIndex($matrix);
if (null === $headerIndex) {
throw new RuntimeException("Ligne d'en-tete introuvable (colonne 'Numero IDTF').");
}
$map = self::buildColumnMap($matrix[$headerIndex]);
if (!isset($map['idtf_number'])) {
throw new RuntimeException("Colonne 'Numero IDTF' introuvable dans l'en-tete.");
}
$rows = [];
foreach (array_slice($matrix, $headerIndex + 1) as $row) {
$idtf = trim((string) ($row[$map['idtf_number']] ?? ''));
// Ligne vide / non exploitable : pas d'identifiant numerique.
if ('' === $idtf || !ctype_digit($idtf)) {
continue;
}
$rows[] = [
'idtf_number' => (int) $idtf,
'product_group' => self::val($row, $map['product_group'] ?? null),
'name' => self::val($row, $map['name'] ?? null) ?? '',
'cleaning_regime' => self::val($row, $map['cleaning_regime'] ?? null) ?? '',
'important_requirements' => self::val($row, $map['important_requirements'] ?? null),
'mandatory_date' => self::parseDate(self::val($row, $map['mandatory_date'] ?? null)),
'related_products' => self::val($row, $map['related_products'] ?? null),
'formula' => self::val($row, $map['formula'] ?? null),
'eural_code' => self::val($row, $map['eural_code'] ?? null),
'cas_numbers' => self::splitCas(self::val($row, $map['cas'] ?? null)),
'footnotes' => self::val($row, $map['footnotes'] ?? null),
];
}
return ['exportDate' => $exportDate, 'rows' => $rows];
}
/**
* Cherche une date "d-m-Y" dans les premieres lignes (preambule
* "Export date: 12-6-2026") et la convertit en "Y-m-d". Null si absente.
*
* @param array<int, array<int, mixed>> $matrix
*/
public static function extractExportDate(array $matrix): ?string
{
foreach (array_slice($matrix, 0, 5) as $row) {
$line = implode(' ', array_map(static fn (mixed $c): string => (string) $c, $row));
if (preg_match('/(\d{1,2})-(\d{1,2})-(\d{4})/', $line, $m)) {
$day = (int) $m[1];
$month = (int) $m[2];
$year = (int) $m[3];
if (checkdate($month, $day, $year)) {
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
}
}
return null;
}
/**
* Index de la ligne d'en-tete : premiere ligne contenant une cellule dont
* le libelle normalise contient "numero idtf".
*
* @param array<int, array<int, mixed>> $matrix
*/
private static function findHeaderIndex(array $matrix): ?int
{
foreach ($matrix as $i => $row) {
foreach ($row as $cell) {
if (str_contains(self::normalize((string) $cell), 'numero idtf')) {
return $i;
}
}
}
return null;
}
/**
* Construit le mapping logique -> index de colonne a partir de la ligne
* d'en-tete (resiste au reordonnancement via fields[]).
*
* @param array<int, mixed> $header
*
* @return array<string, int>
*/
private static function buildColumnMap(array $header): array
{
$map = [];
foreach ($header as $col => $label) {
$n = self::normalize((string) $label);
$key = match (true) {
str_contains($n, 'numero idtf') => 'idtf_number',
str_contains($n, 'product group'),
str_contains($n, 'groupe') => 'product_group',
str_contains($n, 'nom de la marchandise') => 'name',
str_contains($n, 'regime de nettoyage') => 'cleaning_regime',
str_contains($n, 'exigences importantes') => 'important_requirements',
str_contains($n, 'date d application') => 'mandatory_date',
str_contains($n, 'produits apparentes') => 'related_products',
str_contains($n, 'formule') => 'formula',
str_contains($n, 'code eural') => 'eural_code',
str_contains($n, 'numero cas') => 'cas',
str_contains($n, 'annotations') => 'footnotes',
default => null,
};
if (null !== $key && !isset($map[$key])) {
$map[$key] = (int) $col;
}
}
return $map;
}
/**
* Convertit une date "dd-mm-yyyy" en "yyyy-mm-dd". Null si format invalide
* ou date calendaire impossible.
*/
private static function parseDate(?string $raw): ?string
{
if (null === $raw || !preg_match('/^(\d{1,2})-(\d{1,2})-(\d{4})$/', $raw, $m)) {
return null;
}
$day = (int) $m[1];
$month = (int) $m[2];
$year = (int) $m[3];
if (!checkdate($month, $day, $year)) {
return null;
}
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
/**
* Eclate une cellule "Numero CAS" sur ';' en liste de chaines non vides.
*
* @return list<string>
*/
private static function splitCas(?string $raw): array
{
if (null === $raw) {
return [];
}
$parts = array_map('trim', explode(';', $raw));
return array_values(array_filter($parts, static fn (string $v): bool => '' !== $v));
}
/**
* Valeur d'une cellule par index : trim, null si absente/vide.
*
* @param array<int, mixed> $row
*/
private static function val(array $row, ?int $col): ?string
{
if (null === $col) {
return null;
}
$v = trim((string) ($row[$col] ?? ''));
return '' === $v ? null : $v;
}
/**
* Normalise un libelle d'en-tete : minuscules, sans accents ni apostrophes,
* espaces compresses (pour un matching robuste).
*/
private static function normalize(string $s): string
{
$s = str_replace(['', "'"], ' ', $s);
$s = (string) iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
$s = mb_strtolower($s);
return trim((string) preg_replace('/\s+/', ' ', $s));
}
}
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Application\Qualimat;
/**
* Mapping pur d'un item brut de l'API QUALIMAT vers une ligne normalisee
* prete a l'upsert dans `qualimat_carrier`. Sans dependance (testable en
* isolation). Voir ERP-39 § 2 pour les pieges qualite de la source.
*/
final class QualimatRowMapper
{
/**
* Mappe un lot d'items. Les items sans SIRET exploitable sont ignores et
* comptes a part (cf. `rows_skipped` du journal).
*
* @param array<int, array<string, mixed>> $items
*
* @return array{rows: list<array<string, mixed>>, skipped: int}
*/
public static function mapMany(array $items): array
{
$rows = [];
$skipped = 0;
foreach ($items as $item) {
$row = self::mapOne($item);
if (null === $row) {
++$skipped;
continue;
}
$rows[] = $row;
}
return ['rows' => $rows, 'skipped' => $skipped];
}
/**
* Mappe un item unique. Retourne null si le SIRET est absent ou vide
* (ligne inexploitable : pas de cle naturelle pour l'upsert).
*
* @param array<string, mixed> $item
*
* @return null|array<string, mixed>
*/
public static function mapOne(array $item): ?array
{
$siret = self::normalizeSiret(self::str($item['Siret'] ?? null));
if (null === $siret) {
return null;
}
return [
'siret' => $siret,
// Nom et Societe sont identiques a la source : une seule colonne.
'name' => self::str($item['Nom'] ?? null) ?? '',
'address' => self::str($item['Adresse'] ?? null),
'postal_code' => self::str($item['CodePostal'] ?? null),
'city' => self::str($item['Ville'] ?? null),
'phone' => self::str($item['Telephone_1'] ?? null),
'department' => self::str($item['Departement'] ?? null),
// Statut conserve brut (feed externe, valeurs non contraintes).
'status' => self::str($item['Statut'] ?? null) ?? '',
'validity_date' => self::parseDate(self::str($item['Validite'] ?? null)),
];
}
/**
* Normalise un SIRET : ne conserve que les chiffres. Null si vide.
* La source est "sale" (longueurs variables 7 a 14) : aucune contrainte
* de longueur, on stocke les chiffres tels quels.
*/
public static function normalizeSiret(?string $raw): ?string
{
if (null === $raw) {
return null;
}
$digits = preg_replace('/\D+/', '', $raw) ?? '';
return '' === $digits ? null : $digits;
}
/**
* Convertit une date "dd/mm/yyyy" en "yyyy-mm-dd". Null si le format ne
* correspond pas ou si la date n'est pas un jour calendaire valide
* (garde-fou : evite un INSERT en erreur sur une date impossible).
*/
public static function parseDate(?string $raw): ?string
{
if (null === $raw || !preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $raw, $m)) {
return null;
}
$day = (int) $m[1];
$month = (int) $m[2];
$year = (int) $m[3];
if (!checkdate($month, $day, $year)) {
return null;
}
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
/**
* Trim d'une valeur scalaire ; null si la chaine resultante est vide.
*/
private static function str(mixed $value): ?string
{
if (null === $value) {
return null;
}
$trimmed = trim((string) $value);
return '' === $trimmed ? null : $trimmed;
}
}
@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Console;
use App\Module\Transport\Application\Idtf\IdtfSheetParser;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use PhpOffice\PhpSpreadsheet\IOFactory;
use RuntimeException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
use function array_slice;
use function count;
use function in_array;
use const JSON_THROW_ON_ERROR;
use const JSON_UNESCAPED_UNICODE;
/**
* ERP-149 : synchronise le referentiel des codes IDTF (regimes de nettoyage
* transport).
*
* Recupere l'export Excel depuis le generateur icrt-idtf.com (ou un fichier
* local), le parse et synchronise `idtf_product` de facon transactionnelle :
* upsert sur (schema, idtf_number), soft-delete des absents, journal dans
* `idtf_sync_log`. Idempotente (refresh complet).
*/
#[AsCommand(
name: 'app:idtf:sync',
description: 'Synchronise le referentiel des codes IDTF depuis l\'export Excel icrt-idtf.com (upsert + soft-delete + journal).',
)]
final class SyncIdtfCommand extends Command
{
private const string GENERATOR_URL = 'https://www.icrt-idtf.com/fr/excel-generator/';
/**
* Champs a cocher explicitement : `fields[]=all` ne deplie PAS les colonnes
* cote serveur (6 colonnes seulement). Cette liste donne l'export complet
* (11 colonnes). Cf. ERP-149 § 1.
*
* @var list<string>
*/
private const array EXPORT_FIELDS = [
'product_number_idtf',
'product_name',
'minimum_cleaning_regime',
'important_requirements',
'date_mandatory',
'related_products',
'formula',
'product_number_eural',
'product_number_cas',
'footnotes',
];
public function __construct(
private readonly Connection $connection,
private readonly HttpClientInterface $httpClient,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('schema', null, InputOption::VALUE_REQUIRED, "Module IDTF : 'road' (routier) ou 'water' (fluvial).", 'road')
->addOption('file', null, InputOption::VALUE_REQUIRED, "Chemin d'un .xlsx local (court-circuite le telechargement, utile pour tests/rejeu).")
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Analyse sans ecriture en base.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$schema = (string) $input->getOption('schema');
$dryRun = (bool) $input->getOption('dry-run');
$file = $input->getOption('file');
if (!in_array($schema, ['road', 'water'], true)) {
$io->error("--schema doit valoir 'road' ou 'water'.");
return Command::INVALID;
}
// 1. Recuperation du binaire xlsx (local ou via POST).
try {
$xlsx = null !== $file ? $this->readLocal((string) $file) : $this->downloadExport($schema);
} catch (Throwable $e) {
$io->error('Telechargement/lecture impossible : '.$e->getMessage());
return Command::FAILURE;
}
// 2. Parsing (xlsx -> matrice -> lignes normalisees).
try {
$parsed = IdtfSheetParser::parse($this->toMatrix($xlsx));
} catch (Throwable $e) {
$io->error('Parsing impossible : '.$e->getMessage());
return Command::FAILURE;
}
$rows = $parsed['rows'];
$exportDate = $parsed['exportDate'] ?? new DateTimeImmutable()->format('Y-m-d');
$io->section(sprintf('IDTF %s — export du %s', mb_strtoupper($schema), $exportDate));
$io->writeln(sprintf('%d lignes exploitables lues.', count($rows)));
if ($dryRun) {
$this->renderPreview($io, $rows);
$io->note(sprintf('Dry-run : aucune ecriture. (%d lignes au total)', count($rows)));
return Command::SUCCESS;
}
// 3. Sync transactionnelle : upsert -> soft-delete -> journal.
$run = new DateTimeImmutable()->format('Y-m-d H:i:s.u');
$this->connection->beginTransaction();
try {
$upserted = $this->upsertAll($schema, $exportDate, $rows, $run);
$deactivated = $this->deactivateMissing($schema, $run);
$this->log($schema, $exportDate, count($rows), $upserted, $deactivated);
$this->connection->commit();
} catch (Throwable $e) {
$this->connection->rollBack();
$io->error('Sync annulee (rollback) : '.$e->getMessage());
return Command::FAILURE;
}
$io->success(sprintf('%d upsert, %d desactive(s).', $upserted, $deactivated));
return Command::SUCCESS;
}
/**
* Rejoue le POST du generateur pour recuperer le binaire xlsx complet.
* Le formulaire poste sur lui-meme ; pas besoin de GET/cookies prealables.
*/
private function downloadExport(string $schema): string
{
// Corps construit a la main : http-client encoderait fields[] en
// indices numerotes, on veut bien des "fields[]=..." repetes.
$pairs = [
'schema='.$schema,
'type%5B%5D='.$schema,
'roadRegime%5B%5D=all',
'waterRegime%5B%5D=all',
'groups%5B%5D=all',
'products%5B%5D=all',
];
foreach (self::EXPORT_FIELDS as $field) {
$pairs[] = 'fields%5B%5D='.$field;
}
$pairs[] = 'generateExcel=';
$response = $this->httpClient->request('POST', self::GENERATOR_URL, [
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
'body' => implode('&', $pairs),
'timeout' => 90,
]);
$content = $response->getContent();
$contentType = $response->getHeaders(false)['content-type'][0] ?? '';
// Garde-fou : un HTML signifie un POST rejete (filtres/payload).
if (!str_contains($contentType, 'spreadsheet') && !str_starts_with($content, "PK\x03\x04")) {
throw new RuntimeException(sprintf('Reponse non-xlsx (content-type: %s). Verifie le payload.', $contentType));
}
return $content;
}
private function readLocal(string $path): string
{
$raw = @file_get_contents($path);
if (false === $raw) {
throw new RuntimeException(sprintf('Fichier illisible : %s', $path));
}
return $raw;
}
/**
* Charge le binaire xlsx via PhpSpreadsheet et retourne la feuille active
* sous forme de matrice 0-indexee (lignes/colonnes).
*
* @return array<int, array<int, mixed>>
*/
private function toMatrix(string $xlsx): array
{
$tmp = tempnam(sys_get_temp_dir(), 'idtf_').'.xlsx';
file_put_contents($tmp, $xlsx);
try {
// toArray(null, true, true, false) : colonnes 0-indexees.
return IOFactory::load($tmp)->getActiveSheet()->toArray(null, true, true, false);
} finally {
@unlink($tmp);
}
}
/**
* Upsert de toutes les lignes (cle naturelle = schema + idtf_number).
*
* @param list<array<string, mixed>> $rows
*/
private function upsertAll(string $schema, string $exportDate, array $rows, string $run): int
{
$sql = <<<'SQL'
INSERT INTO idtf_product
(idtf_number, schema, product_group, name, cleaning_regime, important_requirements,
mandatory_date, related_products, formula, eural_code, cas_numbers, footnotes,
source_export_date, is_active, last_synced_at)
VALUES
(:idtf, :schema, :grp, :name, :regime, :req, :mdate, :related, :formula, :eural,
CAST(:cas AS JSONB), :foot, :export, TRUE, :run)
ON CONFLICT (schema, idtf_number) DO UPDATE SET
product_group = EXCLUDED.product_group,
name = EXCLUDED.name,
cleaning_regime = EXCLUDED.cleaning_regime,
important_requirements = EXCLUDED.important_requirements,
mandatory_date = EXCLUDED.mandatory_date,
related_products = EXCLUDED.related_products,
formula = EXCLUDED.formula,
eural_code = EXCLUDED.eural_code,
cas_numbers = EXCLUDED.cas_numbers,
footnotes = EXCLUDED.footnotes,
source_export_date = EXCLUDED.source_export_date,
is_active = TRUE,
last_synced_at = EXCLUDED.last_synced_at
SQL;
$count = 0;
foreach ($rows as $r) {
$this->connection->executeStatement($sql, [
'idtf' => $r['idtf_number'],
'schema' => $schema,
'grp' => $r['product_group'],
'name' => $r['name'],
'regime' => $r['cleaning_regime'],
'req' => $r['important_requirements'],
'mdate' => $r['mandatory_date'],
'related' => $r['related_products'],
'formula' => $r['formula'],
'eural' => $r['eural_code'],
'cas' => json_encode($r['cas_numbers'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'foot' => $r['footnotes'],
'export' => $exportDate,
'run' => $run,
]);
++$count;
}
return $count;
}
/**
* Soft-delete : toute ligne du schema active non revue par ce run passe a
* is_active=false.
*/
private function deactivateMissing(string $schema, string $run): int
{
return (int) $this->connection->executeStatement(
'UPDATE idtf_product SET is_active = FALSE WHERE schema = :schema AND is_active = TRUE AND last_synced_at < :run',
['schema' => $schema, 'run' => $run],
);
}
private function log(string $schema, string $exportDate, int $total, int $upserted, int $deactivated): void
{
$this->connection->executeStatement(
<<<'SQL'
INSERT INTO idtf_sync_log (schema, export_date, rows_total, rows_upserted, rows_deactivated)
VALUES (:schema, :export, :total, :upserted, :deactivated)
SQL,
[
'schema' => $schema,
'export' => $exportDate,
'total' => $total,
'upserted' => $upserted,
'deactivated' => $deactivated,
],
);
}
/**
* @param list<array<string, mixed>> $rows
*/
private function renderPreview(SymfonyStyle $io, array $rows): void
{
$io->table(
['IDTF', 'Nom', 'Regime', 'CAS'],
array_map(static fn (array $r): array => [
(string) $r['idtf_number'],
mb_strimwidth((string) $r['name'], 0, 50, '…'),
(string) $r['cleaning_regime'],
implode(', ', $r['cas_numbers']),
], array_slice($rows, 0, 15)),
);
}
}
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Console;
use App\Module\Transport\Application\Qualimat\QualimatRowMapper;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use RuntimeException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
use function array_slice;
use function count;
use function is_array;
use const JSON_THROW_ON_ERROR;
/**
* ERP-39 : synchronise le referentiel des transporteurs QUALIMAT.
*
* Recupere la liste des operateurs de transport depuis l'API publique (ou un
* fichier local), normalise chaque ligne et synchronise `qualimat_carrier` de
* facon transactionnelle : upsert sur le SIRET, soft-delete des absents,
* journal dans `qualimat_sync_log`. Idempotente (refresh complet) : prevue
* pour un cron quotidien.
*/
#[AsCommand(
name: 'app:qualimat:sync',
description: 'Synchronise le referentiel des transporteurs QUALIMAT (upsert + soft-delete + journal).',
)]
final class SyncQualimatCommand extends Command
{
private const string API_URL = 'https://www.qualimat.org/wp-json/qualimat/v1/getOperateurs';
private const int DEFAULT_PPP = 10000;
public function __construct(
private readonly Connection $connection,
private readonly HttpClientInterface $httpClient,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('file', null, InputOption::VALUE_REQUIRED, "Chemin d'un JSON local (court-circuite l'appel HTTP, utile pour tests/rejeu).")
->addOption('ppp', null, InputOption::VALUE_REQUIRED, "Taille de page demandee a l'API.", (string) self::DEFAULT_PPP)
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Analyse sans ecriture en base.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$ppp = max(1, (int) $input->getOption('ppp'));
$dryRun = (bool) $input->getOption('dry-run');
$file = $input->getOption('file');
// 1. Recuperation des items (fichier local ou API).
try {
$items = null !== $file ? $this->readLocal((string) $file) : $this->fetchRemote($ppp);
} catch (Throwable $e) {
$io->error('Recuperation impossible : '.$e->getMessage());
return Command::FAILURE;
}
$total = count($items);
$io->section(sprintf('QUALIMAT — %d items recus', $total));
// Garde-fou troncature : un retour egal a ppp signale un dataset coupe.
if (null === $file && $total === $ppp) {
$io->warning(sprintf("Le nombre d'items recus (%d) egale --ppp : resultat potentiellement tronque, augmente --ppp.", $ppp));
}
// 2. Mapping / normalisation (les items sans SIRET sont ignores).
['rows' => $rows, 'skipped' => $skipped] = QualimatRowMapper::mapMany($items);
$io->writeln(sprintf('%d lignes exploitables, %d ignorees (sans SIRET).', count($rows), $skipped));
if ($dryRun) {
$this->renderPreview($io, $rows);
$io->note(sprintf('Dry-run : aucune ecriture. (%d lignes au total)', count($rows)));
return Command::SUCCESS;
}
// 3. Sync transactionnelle : upsert -> soft-delete -> journal.
$run = new DateTimeImmutable()->format('Y-m-d H:i:s.u');
$this->connection->beginTransaction();
try {
$upserted = $this->upsertAll($rows, $run);
$deactivated = $this->deactivateMissing($run);
$this->log($run, $total, $upserted, $skipped, $deactivated);
$this->connection->commit();
} catch (Throwable $e) {
$this->connection->rollBack();
$io->error('Sync annulee (rollback) : '.$e->getMessage());
return Command::FAILURE;
}
$io->success(sprintf('%d upsert, %d ignore(s), %d desactive(s).', $upserted, $skipped, $deactivated));
return Command::SUCCESS;
}
/**
* Rejoue l'appel GET de l'API QUALIMAT et retourne le tableau d'items.
*
* @return array<int, array<string, mixed>>
*/
private function fetchRemote(int $ppp): array
{
$response = $this->httpClient->request('GET', self::API_URL, [
'query' => ['type' => 'operateur_transport', 'ppp' => $ppp],
'timeout' => 60,
]);
// toArray() leve une exception sur un statut non-2xx ou un corps non-JSON.
$data = $response->toArray();
return array_is_list($data) ? $data : [];
}
/**
* Lit un export JSON local (tableau d'objets).
*
* @return array<int, array<string, mixed>>
*/
private function readLocal(string $path): array
{
$raw = @file_get_contents($path);
if (false === $raw) {
throw new RuntimeException(sprintf('Fichier illisible : %s', $path));
}
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($data) || !array_is_list($data)) {
throw new RuntimeException("Le JSON doit etre un tableau d'objets.");
}
return $data;
}
/**
* Upsert de toutes les lignes valides (cle naturelle = siret). Marque
* is_active=TRUE et tamponne last_synced_at avec le run courant.
*
* @param list<array<string, mixed>> $rows
*/
private function upsertAll(array $rows, string $run): int
{
$sql = <<<'SQL'
INSERT INTO qualimat_carrier
(siret, name, address, postal_code, city, phone, department, status, validity_date, is_active, last_synced_at)
VALUES
(:siret, :name, :address, :postal_code, :city, :phone, :department, :status, :validity_date, TRUE, :run)
ON CONFLICT (siret) DO UPDATE SET
name = EXCLUDED.name,
address = EXCLUDED.address,
postal_code = EXCLUDED.postal_code,
city = EXCLUDED.city,
phone = EXCLUDED.phone,
department = EXCLUDED.department,
status = EXCLUDED.status,
validity_date = EXCLUDED.validity_date,
is_active = TRUE,
last_synced_at = EXCLUDED.last_synced_at
SQL;
$count = 0;
foreach ($rows as $r) {
$this->connection->executeStatement($sql, [
'siret' => $r['siret'],
'name' => $r['name'],
'address' => $r['address'],
'postal_code' => $r['postal_code'],
'city' => $r['city'],
'phone' => $r['phone'],
'department' => $r['department'],
'status' => $r['status'],
'validity_date' => $r['validity_date'],
'run' => $run,
]);
++$count;
}
return $count;
}
/**
* Soft-delete : toute ligne active non revue par ce run (tampon anterieur)
* passe a is_active=false.
*/
private function deactivateMissing(string $run): int
{
return (int) $this->connection->executeStatement(
'UPDATE qualimat_carrier SET is_active = FALSE WHERE is_active = TRUE AND last_synced_at < :run',
['run' => $run],
);
}
private function log(string $run, int $total, int $upserted, int $skipped, int $deactivated): void
{
$this->connection->executeStatement(
<<<'SQL'
INSERT INTO qualimat_sync_log (fetched_at, rows_total, rows_upserted, rows_skipped, rows_deactivated)
VALUES (:run, :total, :upserted, :skipped, :deactivated)
SQL,
[
'run' => $run,
'total' => $total,
'upserted' => $upserted,
'skipped' => $skipped,
'deactivated' => $deactivated,
],
);
}
/**
* @param list<array<string, mixed>> $rows
*/
private function renderPreview(SymfonyStyle $io, array $rows): void
{
$io->table(
['SIRET', 'Nom', 'CP', 'Ville', 'Statut', 'Validite'],
array_map(static fn (array $r): array => [
(string) $r['siret'],
mb_strimwidth((string) $r['name'], 0, 40, '…'),
(string) ($r['postal_code'] ?? ''),
mb_strimwidth((string) ($r['city'] ?? ''), 0, 25, '…'),
(string) $r['status'],
(string) ($r['validity_date'] ?? ''),
], array_slice($rows, 0, 15)),
);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport;
final class TransportModule
{
public const string ID = 'transport';
public const string LABEL = 'Transport';
public const bool REQUIRED = false;
/**
* Liste declarative des permissions RBAC exposees par le module Transport.
*
* Vide a ce stade : le module ne porte que des referentiels externes
* synchronises par commandes console (codes IDTF - ERP-149, transporteurs
* QUALIMAT - ERP-39), sans ecran ni action protegee. Les permissions seront
* ajoutees quand une page de consultation sera exposee.
*
* Consommee par `app:sync-permissions` (un tableau vide est valide).
*
* @return array<int, array{code: string, label: string}>
*/
public static function permissions(): array
{
return [];
}
}
@@ -361,91 +361,6 @@ final class ColumnCommentsCatalog
'iban' => 'IBAN du compte (≤ 34 caracteres).',
'position' => 'Ordre d affichage du RIB dans la liste du fournisseur (croissant).',
] + self::timestampableBlamableComments(),
// 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(),
];
}
@@ -54,8 +54,6 @@ 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.
@@ -1,107 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\CategoryType;
/**
* Tests du seed de la taxonomie PRESTATAIRE (M3 1.1) cote API.
*
* Le multi-select « Categorie » du prestataire (formulaire principal + adresse)
* consomme `GET /api/categories?typeCode=PRESTATAIRE`. Ce test prouve que :
* - le filtre `?typeCode=PRESTATAIRE` ne renvoie QUE les categories du type
* PRESTATAIRE (aucune fuite de categorie d'un autre type) ;
* - chaque membre renvoye porte bien le type PRESTATAIRE dans `categoryTypes`.
*
* NB : la base de test est purgee de toute categorie / type entre chaque test
* (cf. AbstractCatalogApiTestCase::cleanupCatalogTestData), donc le type et les
* categories PRESTATAIRE sont materialises ici (et non lus depuis le seed de la
* migration / fixture, qui ne survit pas a la purge). On valide ainsi le contrat
* du filtre sur le code reel `PRESTATAIRE`. La presence du seed apres un
* `make db-reset` reel est, elle, verifiee par l'idempotence des fixtures.
*
* @internal
*/
final class CategoryPrestataireSeedTest extends AbstractCatalogApiTestCase
{
/**
* Categories de demonstration seedees par la migration / fixture PRESTATAIRE.
*/
private const array PROVIDER_CATEGORIES = [
'Maintenance industrielle',
'Nettoyage',
'Transport',
];
public function testTypeCodePrestataireReturnsOnlyProviderCategories(): void
{
$providerType = $this->getOrCreatePrestataireType();
foreach (self::PROVIDER_CATEGORIES as $name) {
$this->createCategory($name, $providerType);
}
// Bruit : un type + une categorie d'un autre type ne doivent PAS fuiter.
$noiseType = $this->createCategoryType('TEST_FOURNISSEUR', 'Test Fournisseur');
$this->createCategory(self::TEST_CATEGORY_PREFIX.'noise', $noiseType);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories?typeCode=PRESTATAIRE&pagination=false');
self::assertSame(200, $response->getStatusCode());
$members = $response->toArray()['member'];
$names = array_map(static fn (array $m): string => $m['name'], $members);
sort($names);
$expected = self::PROVIDER_CATEGORIES;
sort($expected);
self::assertSame(
$expected,
$names,
'Le filtre ?typeCode=PRESTATAIRE doit ne renvoyer QUE les categories du type PRESTATAIRE.',
);
// Chaque categorie remontee doit PORTER le type PRESTATAIRE.
foreach ($members as $member) {
self::assertContains('PRESTATAIRE', array_column($member['categoryTypes'], 'code'));
}
}
public function testTypeCodePrestataireKeepsHydraPagination(): void
{
$providerType = $this->getOrCreatePrestataireType();
$this->createCategory('Maintenance industrielle', $providerType);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories?typeCode=PRESTATAIRE');
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
self::assertArrayHasKey('totalItems', $data, 'Le filtre ne doit pas casser la pagination Hydra.');
self::assertArrayHasKey('member', $data);
foreach ($data['member'] as $member) {
self::assertContains('PRESTATAIRE', array_column($member['categoryTypes'], 'code'));
}
}
/**
* Recupere le type PRESTATAIRE reel, ou le cree s'il est absent. Le code
* `PRESTATAIRE` est seede par CategoryTypeFixtures (present en debut de suite),
* mais le cleanup purge tous les `category_type` entre les tests : selon
* l'ordre d'execution, le type peut donc exister ou non. Le get-or-create rend
* le test robuste sans dependre du seed ni le dupliquer.
*/
private function getOrCreatePrestataireType(): CategoryType
{
$em = $this->getEm();
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'PRESTATAIRE']);
if ($existing instanceof CategoryType) {
return $existing;
}
return $this->createCategoryType('PRESTATAIRE', 'Prestataire');
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Validation back-autoritative du FORMAT de la date de creation (foundedAt,
* onglet Information).
*
* Le front (MalioDate, cf. MUI-44) forwarde desormais la saisie brute invalide
* au serveur plutot que de l'avaler. Cote back, une date non parsable doit
* produire un 422 porte sur `foundedAt` (mappable inline par useFormErrors),
* et non un 400 generique. Repose sur `collectDenormalizationErrors` actif sur
* l'operation Patch du Client.
*
* @internal
*/
final class ClientFoundedAtFormatTest extends AbstractCommercialApiTestCase
{
private const string MERGE = 'application/merge-patch+json';
/** Date non parsable -> 422 porte sur foundedAt (et pas un 400 generique). */
public function testFoundedAtNonParsableEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Founded Format SARL');
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '32/13/2026'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
}
/**
* Cas piege : « 12/25/2026 » est invalide cote front (JJ/MM/AAAA -> mois 25)
* mais PHP DateTime l'accepterait en M/J/AAAA (25 decembre). Le format d'entree
* strict ISO `Y-m-d` (Context sur foundedAt) doit le rejeter -> 422.
*/
public function testFoundedAtFormatAmbiguUsEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Founded Ambigu SARL');
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '12/25/2026'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
}
/** Non-regression : une date ISO valide reste acceptee (200). */
public function testFoundedAtIsoValideEst200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Founded Ok SARL');
$data = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '2010-05-01'],
])->toArray();
self::assertResponseStatusCodeSame(200);
self::assertStringStartsWith('2010-05-01', $data['foundedAt']);
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Validation back-autoritative du FORMAT de la date de creation (foundedAt,
* onglet Information) du fournisseur. Miroir de {@see ClientFoundedAtFormatTest}.
*
* Une date non parsable (saisie brute forwardee par MalioDate, MUI-44) doit
* produire un 422 porte sur `foundedAt` (mappable inline par useFormErrors), et
* non un 400 generique. Repose sur `collectDenormalizationErrors` sur les
* operations write du Supplier.
*
* @internal
*/
final class SupplierFoundedAtFormatTest extends AbstractSupplierApiTestCase
{
/** Date non parsable -> 422 porte sur foundedAt (et pas un 400 generique). */
public function testFoundedAtNonParsableEst422(): void
{
$seed = $this->seedSupplier('Founded Format Negoce');
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '32/13/2026'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
}
/**
* Cas piege : « 12/25/2026 » est invalide cote front (JJ/MM/AAAA -> mois 25)
* mais PHP DateTime l'accepterait en M/J/AAAA. Le format d'entree strict ISO
* `Y-m-d` (Context sur foundedAt) doit le rejeter -> 422.
*/
public function testFoundedAtFormatAmbiguUsEst422(): void
{
$seed = $this->seedSupplier('Founded Ambigu Negoce');
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '12/25/2026'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
}
/** Non-regression : une date ISO valide reste acceptee (200). */
public function testFoundedAtIsoValideEst200(): void
{
$seed = $this->seedSupplier('Founded Ok Negoce');
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$data = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '2010-05-01'],
])->toArray();
self::assertResponseStatusCodeSame(200);
self::assertStringStartsWith('2010-05-01', $data['foundedAt']);
}
}
@@ -1,371 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderContact;
use App\Module\Technique\Domain\Entity\ProviderRib;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
use DateTimeImmutable;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Base des tests fonctionnels du repertoire prestataires (M3 module Technique).
* Jumelle de la base fournisseurs (M2), recentree sur le perimetre ERP-134
* (Provider + Processor + cloisonnement site).
*
* Donnees (RETEX M1/M2 pas de fixtures globales pour les tests) : chaque test
* seede ses prestataires en base via les helpers ci-dessous, puis le tearDown les
* purge. Les 3 sites (Chatellerault 86 / Saint-Jean 17 / Pommevic 82) sont seedes
* par SitesFixtures (make test-db-setup) ; on les recupere par code postal.
*
* Categories : `providerCategory('NETTOYAGE')` fetch-or-create une categorie de
* type PRESTATAIRE (requis par RG-3.09). Pour fabriquer une categorie d'un AUTRE
* type (test de rejet RG-3.09), utiliser `foreignCategory()`.
*
* Cleanup : tearDown purge prestataires AVANT categories/users (provider_category
* et provider_site sont ON DELETE CASCADE cote provider le DELETE DQL sur
* Provider libere categories et sites pour les purges suivantes).
*
* @internal
*/
abstract class AbstractProviderApiTestCase extends AbstractApiTestCase
{
protected const string LD = 'application/ld+json';
protected const string MERGE = 'application/merge-patch+json';
protected const string TEST_CATEGORY_PREFIX = 'test_prov_cat_';
/** Codes postaux des 3 sites fixtures (cf. SitesFixtures). */
protected const string SITE_86 = '86100'; // Chatellerault
protected const string SITE_17 = '17400'; // Saint-Jean
protected const string SITE_82 = '82400'; // Pommevic
/** IBAN / BIC valides (memes valeurs que les tests M2) pour les RIB. */
protected const string VALID_IBAN = 'FR1420041010050500013M02606';
protected const string VALID_BIC = 'BNPAFRPPXXX';
/** BIC d'un autre pays (DE) : controle croise pays BIC/IBAN. */
protected const string FOREIGN_BIC = 'DEUTDEFFXXX';
protected function tearDown(): void
{
$em = $this->getEm();
$em->createQuery('DELETE FROM '.Provider::class)->execute();
$em->createQuery('DELETE FROM '.Category::class.' c WHERE c.name LIKE :prefix')
->setParameter('prefix', self::TEST_CATEGORY_PREFIX.'%')->execute()
;
$em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix')
->setParameter('prefix', 'test_%')->execute()
;
$em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix')
->setParameter('prefix', 'test_%')->execute()
;
parent::tearDown();
}
protected function createAdminClient(): Client
{
return $this->authenticatedClient('admin', 'admin');
}
/**
* Recupere (ou cree) le type PRESTATAIRE. Idempotent (unicite category_type.code).
*/
protected function providerCategoryType(): CategoryType
{
$em = $this->getEm();
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'PRESTATAIRE']);
if (null !== $existing) {
return $existing;
}
$type = new CategoryType();
$type->setCode('PRESTATAIRE');
$type->setLabel('Prestataire');
$em->persist($type);
$em->flush();
return $type;
}
/**
* Fetch-or-create une categorie de type PRESTATAIRE par code (defaut NETTOYAGE).
* Idempotent (lookup par code, aligne sur l'index unique partiel uq_category_code)
* et auto-suffisant. Nom prefixe -> purge par tearDown.
*/
protected function providerCategory(string $code = 'NETTOYAGE'): Category
{
$em = $this->getEm();
$existing = $em->getRepository(Category::class)->findOneBy(['code' => $code, 'deletedAt' => null]);
if (null !== $existing) {
return $existing;
}
$category = new Category();
$category->setName(self::TEST_CATEGORY_PREFIX.strtolower($code));
$category->setCode($code);
$category->addCategoryType($this->providerCategoryType());
$em->persist($category);
$em->flush();
return $category;
}
/**
* Cree une categorie d'un type DIFFERENT de PRESTATAIRE (pour tester le rejet
* RG-3.09). Code unique pour ne pas collisionner avec une categorie existante.
*/
protected function foreignCategory(): Category
{
$em = $this->getEm();
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$type = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'CLIENT']);
if (null === $type) {
$type = new CategoryType();
$type->setCode('CLIENT');
$type->setLabel('Client');
$em->persist($type);
}
$category = new Category();
$category->setName(self::TEST_CATEGORY_PREFIX.'foreign_'.$suffix);
$category->setCode('FOREIGN_'.strtoupper($suffix));
$category->addCategoryType($type);
$em->persist($category);
$em->flush();
return $category;
}
/**
* Recupere un site fixture par code postal (cf. SitesFixtures). Echoue
* explicitement si absent (fixtures non chargees / module Sites off).
*/
protected function site(string $postalCode): Site
{
$site = $this->getEm()->getRepository(Site::class)->findOneBy(['postalCode' => $postalCode]);
self::assertNotNull(
$site,
sprintf('Site fixture "%s" introuvable : SitesFixtures charge (make test-db-setup) ?', $postalCode),
);
return $site;
}
/**
* Seede directement un Provider minimal (sans passer par l'API), pour les tests
* de liste / archivage / cloisonnement. Nom stocke en MAJUSCULES pour refleter
* l'etat normalise (RG-3.11) qu'aurait produit le ProviderProcessor. Porte une
* categorie PRESTATAIRE + les sites donnes (par code postal).
*
* @param list<string> $sitePostalCodes codes postaux des sites a rattacher
*/
protected function seedProvider(
string $companyName,
array $sitePostalCodes = [self::SITE_86],
bool $isArchived = false,
string $categoryCode = 'NETTOYAGE',
?string $siren = null,
): Provider {
$em = $this->getEm();
$provider = new Provider();
$provider->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
$provider->addCategory($this->providerCategory($categoryCode));
foreach ($sitePostalCodes as $postalCode) {
$provider->addSite($this->site($postalCode));
}
if (null !== $siren) {
$provider->setSiren($siren);
}
$provider->setIsArchived($isArchived);
if ($isArchived) {
$provider->setArchivedAt(new DateTimeImmutable());
}
$em->persist($provider);
$em->flush();
return $provider;
}
/**
* Payload minimal valide du formulaire principal (companyName + 1 categorie
* PRESTATAIRE + sites donnes). Categorie NETTOYAGE par defaut.
*
* @param list<string> $sitePostalCodes
*
* @return array<string, mixed>
*/
protected function validMainPayload(string $companyName, array $sitePostalCodes = [self::SITE_86]): array
{
$siteIris = array_map(fn (string $pc): string => '/api/sites/'.$this->site($pc)->getId(), $sitePostalCodes);
return [
'companyName' => $companyName,
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
'sites' => $siteIris,
];
}
/**
* Cree un utilisateur non-admin CLOISONNE : porte les permissions donnees via
* un role jetable, rattache aux seuls sites donnes (par code postal), avec un
* currentSite positionne. N'a PAS `sites.bypass_scope` (sauf si fourni dans
* $permissionCodes) -> sujet ideal des tests de cloisonnement (RG-3.17).
*
* Contrairement a createUserWithPermissions() (parent, qui attache TOUS les
* sites et ne pose pas de currentSite), ce helper controle finement le
* perimetre site de l'user.
*
* @param list<string> $permissionCodes
* @param list<string> $sitePostalCodes sites a rattacher (user_site)
*
* @return array{username: string, password: string}
*/
protected function createScopedUser(
array $permissionCodes,
array $sitePostalCodes,
?string $currentSitePostalCode = null,
): array {
$em = $this->getEm();
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$username = 'test_scoped_'.$suffix;
$password = 'testpass';
/** @var UserPasswordHasherInterface $hasher */
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$role = new Role('test_'.$suffix, 'Test Role '.$suffix, false);
foreach ($permissionCodes as $code) {
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => $code]);
self::assertNotNull($permission, sprintf('Permission "%s" introuvable (app:sync-permissions ?).', $code));
$role->addPermission($permission);
}
$em->persist($role);
$user = new User();
$user->setUsername($username);
$user->setIsAdmin(false);
$user->setPassword($hasher->hashPassword($user, $password));
$user->addRbacRole($role);
foreach ($sitePostalCodes as $postalCode) {
$user->addSite($this->site($postalCode));
}
if (null !== $currentSitePostalCode) {
$user->setCurrentSite($this->site($currentSitePostalCode));
}
$em->persist($user);
$em->flush();
$em->clear();
return ['username' => $username, 'password' => $password];
}
/**
* Ajoute un contact a un prestataire deja persiste (seed direct).
*/
protected function addContact(
Provider $provider,
?string $firstName = 'Marie',
?string $lastName = 'Martin',
?string $phonePrimary = null,
?string $email = null,
int $position = 0,
): ProviderContact {
$contact = new ProviderContact();
$contact->setProvider($provider);
$contact->setFirstName($firstName);
$contact->setLastName($lastName);
$contact->setPhonePrimary($phonePrimary);
$contact->setEmail($email);
$contact->setPosition($position);
$provider->addContact($contact);
$this->getEm()->persist($contact);
$this->getEm()->flush();
return $contact;
}
/**
* Ajoute un RIB a un prestataire deja persiste (seed direct).
*/
protected function addRib(Provider $provider, string $label = 'Compte principal'): ProviderRib
{
$rib = new ProviderRib();
$rib->setProvider($provider);
$rib->setLabel($label);
$rib->setBic(self::VALID_BIC);
$rib->setIban(self::VALID_IBAN);
$provider->addRib($rib);
$this->getEm()->persist($rib);
$this->getEm()->flush();
return $rib;
}
/**
* Recupere un type de reglement seede (CommercialReferentialFixtures) par code
* (ex. LCR, VIREMENT). Echoue explicitement si absent (fixtures non chargees).
*/
protected function paymentType(string $code): PaymentType
{
$paymentType = $this->getEm()->getRepository(PaymentType::class)->findOneBy(['code' => $code]);
self::assertNotNull(
$paymentType,
sprintf('Type de reglement "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
);
return $paymentType;
}
/**
* Recupere une banque seedee (CommercialReferentialFixtures) par code (ex. SG).
* Echoue explicitement si absente (fixtures non chargees).
*/
protected function bank(string $code): Bank
{
$bank = $this->getEm()->getRepository(Bank::class)->findOneBy(['code' => $code]);
self::assertNotNull(
$bank,
sprintf('Banque "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
);
return $bank;
}
/**
* Indexe les violations d'un corps 422 par propertyPath (assert ciblee).
*
* @param array<string, mixed> $body corps decode (toArray(false))
*
* @return array<string, string> propertyPath => message
*/
protected function violationsByPath(array $body): array
{
$byPath = [];
foreach ($body['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
return $byPath;
}
}
@@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
/**
* Tests fonctionnels des RG comptables inter-champs portees par les Assert\Callback
* de l'entite Provider (M3, RG-3.07 / RG-3.08), via le PATCH de l'onglet
* Comptabilite (groupe provider:write:accounting). On asserte le code HTTP et le
* propertyPath de la violation (consommable par extractApiViolations cote front,
* ERP-101). Jumeau de SupplierAccountingApiTest (M2), sans le bloc « completude de
* l'onglet » : le prestataire est minimal et n'impose pas les six scalaires
* comptables (spec M3 § 3.1).
*
* @internal
*/
final class ProviderAccountingValidationTest extends AbstractProviderApiTestCase
{
// === RG-3.07 : Virement impose une banque ===
public function testVirementWithoutBankReturns422OnBankPath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Virement No Bank');
$response = $client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('VIREMENT')->getId()],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('bank', $this->violationsByPath($response->toArray(false)));
}
public function testVirementWithBankReturns200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Virement With Bank');
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => [
'paymentType' => '/api/payment_types/'.$this->paymentType('VIREMENT')->getId(),
'bank' => '/api/banks/'.$this->bank('SG')->getId(),
],
]);
self::assertResponseStatusCodeSame(200);
}
// === RG-3.08 : LCR impose au moins un RIB (volet ecriture du formulaire) ===
public function testLcrWithoutRibReturns422OnPaymentTypePath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Lcr No Rib');
$response = $client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()],
]);
self::assertResponseStatusCodeSame(422);
// Miroir client : violation portee sur `paymentType` (select « Type de
// règlement »), les RIB n'ayant pas de champ de formulaire pour l'ancrer.
self::assertArrayHasKey('paymentType', $this->violationsByPath($response->toArray(false)));
}
public function testLcrWithRibReturns200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Lcr With Rib');
$this->addRib($seed);
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()],
]);
self::assertResponseStatusCodeSame(200);
}
// violationsByPath() : helper mutualise dans AbstractProviderApiTestCase.
}
@@ -1,115 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
/**
* Tests fonctionnels du formulaire principal prestataire (POST + PATCH) ERP-134.
* Couvre : creation (RG-3.03 sites obligatoires, RG-3.09 type categorie),
* normalisation companyName (RG-3.11), 409 doublon (RG-3.10).
*
* @internal
*/
final class ProviderApiTest extends AbstractProviderApiTestCase
{
public function testPostMainCreatesProvider(): void
{
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Maintenance Pro', [self::SITE_86]),
]);
self::assertSame(201, $response->getStatusCode());
$body = $response->toArray();
// RG-3.11 : companyName normalise en MAJUSCULES.
self::assertSame('MAINTENANCE PRO', $body['companyName']);
self::assertArrayHasKey('id', $body);
// sites embarque (relation directe, site:read) avec name/postalCode.
self::assertCount(1, $body['sites']);
self::assertSame('86100', $body['sites'][0]['postalCode']);
}
public function testPostWithoutSiteIsRejected(): void
{
$client = $this->createAdminClient();
$payload = $this->validMainPayload('Sans Site', [self::SITE_86]);
$payload['sites'] = [];
$response = $client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $payload,
]);
// RG-3.03 : au moins un site obligatoire.
self::assertSame(422, $response->getStatusCode());
self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false)));
}
public function testPostWithoutCategoryIsRejected(): void
{
$client = $this->createAdminClient();
$payload = $this->validMainPayload('Sans Categorie', [self::SITE_86]);
$payload['categories'] = [];
$response = $client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $payload,
]);
// RG-3.09 : au moins une categorie obligatoire.
self::assertSame(422, $response->getStatusCode());
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
}
public function testPostWithForeignCategoryTypeIsRejected(): void
{
$client = $this->createAdminClient();
$foreign = $this->foreignCategory();
$payload = $this->validMainPayload('Mauvais Type', [self::SITE_86]);
$payload['categories'] = ['/api/categories/'.$foreign->getId()];
$response = $client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $payload,
]);
// RG-3.09 : categorie hors type PRESTATAIRE -> 422 sur `categories`.
self::assertSame(422, $response->getStatusCode());
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
}
public function testDuplicateCompanyNameReturns409(): void
{
$this->seedProvider('Doublon Sarl', [self::SITE_86]);
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
// Casse differente : l'unicite est insensible a la casse (LOWER).
'json' => $this->validMainPayload('doublon sarl', [self::SITE_86]),
]);
// RG-3.10 : doublon de nom (case-insensitive) -> 409.
self::assertSame(409, $response->getStatusCode());
}
public function testSameNameAfterArchiveIsAllowed(): void
{
// Index partiel : l'unicite ignore les archives -> reutilisation du nom OK.
$this->seedProvider('Recyclage Express', [self::SITE_86], isArchived: true);
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Recyclage Express', [self::SITE_86]),
]);
self::assertSame(201, $response->getStatusCode());
}
}
@@ -1,319 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Tests fonctionnels de l'export XLSX du repertoire prestataires (M3, § 4.6).
* Jumeau du {@see \App\Tests\Module\Commercial\Api\SupplierExportControllerTest}
* (M2), augmente du cloisonnement par site (§ 2.13, propre au M3).
*
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
* archives par defaut, respect du filtre ?search, peuplement des colonnes contact
* principal / categories / sites (relation directe provider.sites), gating de la
* colonne SIREN selon technique.providers.accounting.view (admin ET user minimal a
* permission explicite), dedup (prestataire multi-categories rendu sur une seule
* ligne), cloisonnement par site (un user cloisonne n'exporte que son site), 403
* sans technique.providers.view, 401 anonyme.
*
* @internal
*/
final class ProviderExportControllerTest extends AbstractProviderApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/providers/export.xlsx';
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
{
$client = $this->createAdminClient();
$this->seedProvider('Export Alpha');
$response = $client->request('GET', self::EXPORT_URL);
self::assertResponseIsSuccessful();
$headers = $response->getHeaders(false);
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
$disposition = $headers['content-disposition'][0] ?? '';
self::assertStringContainsString('attachment; filename="repertoire-prestataires-', $disposition);
self::assertMatchesRegularExpression(
'/filename="repertoire-prestataires-\d{8}\.xlsx"/',
$disposition,
);
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
$grid = $this->gridFromResponse($response->getContent());
$headers = $grid[0];
self::assertSame('Nom prestataire', $headers[0]);
self::assertContains('Contact principal', $headers);
self::assertContains('Téléphone principal', $headers);
self::assertContains('Téléphone secondaire', $headers);
self::assertContains('Email', $headers);
self::assertContains('Catégories', $headers);
self::assertContains('Sites', $headers);
self::assertContains('Date de création', $headers);
}
public function testExportExcludesArchivedByDefault(): void
{
$client = $this->createAdminClient();
$this->seedProvider('Active One');
$this->seedProvider('Archived One', [self::SITE_86], true);
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('ACTIVE ONE', $names);
self::assertNotContains('ARCHIVED ONE', $names);
}
public function testExportRespectsSearchFilter(): void
{
$client = $this->createAdminClient();
$this->seedProvider('Searchable Alpha');
$this->seedProvider('Other Beta');
$names = $this->companyNames(
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
);
self::assertContains('SEARCHABLE ALPHA', $names);
self::assertNotContains('OTHER BETA', $names);
}
/**
* Les colonnes contact sont alimentees par le CONTACT PRINCIPAL : le contact
* de plus petit `position` (decision D2, § 4.6). On seede deux contacts en
* ordre de position inverse pour garantir que c'est bien le principal (et non
* le premier insere) qui alimente la ligne.
*/
public function testExportUsesPrincipalContactColumns(): void
{
$client = $this->createAdminClient();
$provider = $this->seedProvider('Contact Co');
// position 1 (secondaire) insere en premier...
$this->addContact($provider, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1);
// ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
$principal = $this->addContact($provider, 'Alice', 'Principal', '0612345678', 'alice@contact.co', 0);
// Le telephone secondaire n'est pas porte par le helper de base : on le pose
// directement sur le contact principal pour alimenter la colonne dediee.
$principal->setPhoneSecondary('0698765432');
$this->getEm()->flush();
$row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO');
self::assertNotNull($row, 'Ligne « CONTACT CO » introuvable dans l\'export.');
self::assertSame('Principal Alice', $row[1]);
self::assertSame('0612345678', $row[2]);
self::assertSame('0698765432', $row[3]);
self::assertSame('alice@contact.co', $row[4]);
}
/**
* Colonnes « Catégories » et « Sites » : un oubli d'hydratation les rendrait
* vides sans erreur (cf. ERP-100 cote client). Le site est porte EN DIRECT par
* le prestataire (RG-3.03), contrairement au fournisseur M2 (via l'adresse).
*/
public function testExportPopulatesCategoryAndSiteColumns(): void
{
$client = $this->createAdminClient();
$this->seedProvider('Hydrate Co', [self::SITE_86], false, 'NETTOYAGE');
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
// Colonne « Catégories » : libelle de la categorie PRESTATAIRE (getName()).
// Derive du helper de base (idempotent) plutot que de hardcoder le prefixe.
self::assertStringContainsString((string) $this->providerCategory('NETTOYAGE')->getName(), $flat);
// Colonne « Sites » : site rattache en direct au prestataire (RG-3.03).
self::assertStringContainsString((string) $this->site(self::SITE_86)->getName(), $flat);
}
public function testSirenColumnPresentWithAccountingView(): void
{
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
$client = $this->createAdminClient();
$this->seedProvider('Siren Co', [self::SITE_86], false, 'NETTOYAGE', '123456789');
$grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('SIREN', $grid[0]);
self::assertStringContainsString('123456789', $this->flatten($grid));
}
public function testSirenColumnAbsentWithoutAccountingView(): void
{
// Seed via admin, puis relecture par un user qui n'a QUE providers.view.
$this->createAdminClient();
$this->seedProvider('No Siren Co', [self::SITE_86], false, 'NETTOYAGE', '987654321');
$creds = $this->createUserWithPermission('technique.providers.view');
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
self::assertNotContains('SIREN', $grid[0]);
self::assertStringNotContainsString('987654321', $this->flatten($grid));
}
/**
* Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) :
* un user minimal portant uniquement technique.providers.view +
* technique.providers.accounting.view voit bien la colonne SIREN et sa valeur.
* Complement de testSirenColumnPresentWithAccountingView (admin), qui ne prouve
* pas que accounting.view SEULE suffit (l'admin bypasse le RBAC). Le pendant
* negatif est couvert par testSirenColumnAbsentWithoutAccountingView.
*/
public function testSirenColumnPresentForMinimalUserWithAccountingView(): void
{
// Seed via admin, puis relecture par un user non-admin a 2 permissions.
$this->createAdminClient();
$this->seedProvider('Gated Siren Co', [self::SITE_86], false, 'NETTOYAGE', '456789123');
$creds = $this->createUserWithPermissions([
'technique.providers.view',
'technique.providers.accounting.view',
]);
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('SIREN', $grid[0]);
self::assertStringContainsString('456789123', $this->flatten($grid));
}
/**
* Dedup : un prestataire portant >= 2 categories PRESTATAIRE est multiplie par
* la jointure (selection/hydratation des collections) ; l'export doit le rendre
* sur UNE SEULE ligne. On seede un prestataire a 2 categories et on assert qu'il
* n'apparait qu'une fois dans la colonne « Nom prestataire ».
*/
public function testExportDeduplicatesProviderWithMultipleCategories(): void
{
$client = $this->createAdminClient();
$provider = $this->seedProvider('Multi Cat Co', [self::SITE_86], false, 'NETTOYAGE');
// 2e categorie PRESTATAIRE sur le meme prestataire.
$provider->addCategory($this->providerCategory('SECURITE'));
$this->getEm()->flush();
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
$occurrences = count(array_filter($names, static fn (string $name): bool => 'MULTI CAT CO' === $name));
self::assertSame(
1,
$occurrences,
'Un prestataire multi-categories doit apparaitre sur une seule ligne (dedup).',
);
}
/**
* Cloisonnement par site (RG-3.17, § 2.13) : un user non-bypass cloisonne sur
* le site 86 n'exporte QUE les prestataires rattaches au site 86 les
* prestataires des sites 17 / 82 sont exclus, comme dans la liste. Pendant
* export de ProviderSiteScopeTest::testListIsScopedToCurrentSiteForNonBypassUser.
*/
public function testExportIsScopedToCurrentSiteForNonBypassUser(): void
{
// Pre-requis : module Sites actif (sinon currentSite = null, cloisonnement
// no-op et ce test perd son sens).
$this->skipIfSitesModuleDisabled();
$this->createAdminClient();
$this->seedProvider('Presta Site 86', [self::SITE_86]);
$this->seedProvider('Presta Site 17', [self::SITE_17]);
$this->seedProvider('Presta Site 82', [self::SITE_82]);
$creds = $this->createScopedUser(
['technique.providers.view'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('PRESTA SITE 86', $names);
self::assertNotContains('PRESTA SITE 17', $names);
self::assertNotContains('PRESTA SITE 82', $names);
}
public function testForbiddenWithoutProvidersViewPermission(): void
{
$creds = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(403);
}
public function testUnauthorizedWhenAnonymous(): void
{
$client = self::createClient();
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(401);
}
/**
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
*
* @return array<int, array<int, mixed>>
*/
private function gridFromResponse(string $binary): array
{
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_');
self::assertIsString($tmp);
file_put_contents($tmp, $binary);
try {
return IOFactory::load($tmp)->getActiveSheet()->toArray();
} finally {
@unlink($tmp);
}
}
/**
* Extrait la colonne « Nom prestataire » (1re colonne) des lignes de donnees.
*
* @return list<string>
*/
private function companyNames(string $binary): array
{
$grid = $this->gridFromResponse($binary);
$rows = array_slice($grid, 1); // saute l'en-tete
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
}
/**
* Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName.
*
* @return null|array<int, mixed>
*/
private function rowFor(string $binary, string $companyName): ?array
{
foreach (array_slice($this->gridFromResponse($binary), 1) as $row) {
if ((string) ($row[0] ?? '') === $companyName) {
return $row;
}
}
return null;
}
/**
* Aplatit toute la grille en une chaine, pour les assertions de presence.
*
* @param array<int, array<int, mixed>> $grid
*/
private function flatten(array $grid): string
{
return implode('|', array_map(
static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)),
$grid,
));
}
}
@@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
/**
* Tests de la liste paginee /api/providers (ProviderProvider) ERP-134.
* Couvre : envelope Hydra, tri companyName ASC, exclusion des archives,
* ?includeArchived (RG-3.16). Joue en admin (bypass_scope -> pas de cloisonnement).
*
* @internal
*/
final class ProviderListTest extends AbstractProviderApiTestCase
{
public function testListReturnsHydraEnvelopeSortedByName(): void
{
$this->seedProvider('Zeta Services', [self::SITE_86]);
$this->seedProvider('Alpha Nettoyage', [self::SITE_86]);
$this->seedProvider('Mu Maintenance', [self::SITE_86]);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/providers', [
'headers' => ['Accept' => self::LD],
]);
self::assertSame(200, $response->getStatusCode());
$body = $response->toArray();
// Envelope Hydra : totalItems present + member.
self::assertSame(3, $body['totalItems']);
$names = array_column($body['member'], 'companyName');
// Tri companyName ASC (RG-3.16) — noms normalises en MAJUSCULES.
self::assertSame(['ALPHA NETTOYAGE', 'MU MAINTENANCE', 'ZETA SERVICES'], $names);
}
public function testListExcludesArchivedByDefault(): void
{
$this->seedProvider('Actif Sas', [self::SITE_86]);
$this->seedProvider('Archive Sarl', [self::SITE_86], isArchived: true);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/providers', [
'headers' => ['Accept' => self::LD],
]);
self::assertSame(200, $response->getStatusCode());
$body = $response->toArray();
self::assertSame(1, $body['totalItems']);
self::assertSame('ACTIF SAS', $body['member'][0]['companyName']);
}
public function testListIncludeArchivedReintegratesArchived(): void
{
$this->seedProvider('Actif Sas', [self::SITE_86]);
$this->seedProvider('Archive Sarl', [self::SITE_86], isArchived: true);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/providers?includeArchived=true', [
'headers' => ['Accept' => self::LD],
]);
self::assertSame(200, $response->getStatusCode());
self::assertSame(2, $response->toArray()['totalItems']);
}
public function testListFiltersBySiteIdViaDirectRelation(): void
{
$this->seedProvider('Site 86 Only', [self::SITE_86]);
$this->seedProvider('Site 17 Only', [self::SITE_17]);
$client = $this->createAdminClient();
$site17 = $this->site(self::SITE_17);
$response = $client->request('GET', '/api/providers?siteId='.$site17->getId(), [
'headers' => ['Accept' => self::LD],
]);
self::assertSame(200, $response->getStatusCode());
$body = $response->toArray();
self::assertSame(1, $body['totalItems']);
self::assertSame('SITE 17 ONLY', $body['member'][0]['companyName']);
}
}
@@ -1,279 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Matrice RBAC complete du repertoire prestataires par role metier (spec-back M3
* § 2.9 + § 2.13, ERP-138). Valide 200/403 par verbe et par onglet pour
* bureau / compta / commerciale / usine, le gating des champs comptables en
* lecture (omission de cle) et le cloisonnement par site de l'Usine.
*
* Les comptes demo et la matrice sont seedes via la commande reelle
* `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente
* pas de mock de role. Jumeau de SupplierRBACMatrixTest (M2), avec la difference
* structurante du M3 : l'Usine n'est plus « 403 partout » mais possede
* `technique.providers.view` en lecture seule, CLOISONNEE a son site courant
* (pas de `sites.bypass_scope`).
*
* Matrice § 2.9 (ERP-138) rappel :
* - bureau : providers.view + manage (ni accounting, ni archive) + bypass_scope
* - compta : providers.view + accounting.view + accounting.manage (PAS manage) + bypass_scope
* - commerciale : providers.view + manage (PAS accounting) + bypass_scope
* - usine : providers.view seul, SANS bypass_scope (cloisonne a son site)
* - archive : admin seul (aucun role metier)
*
* @internal
*/
final class ProviderRBACMatrixTest extends AbstractProviderApiTestCase
{
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
protected function setUp(): void
{
parent::setUp();
// Seed idempotent via la commande applicative (roles + matrice § 2.9 +
// comptes demo). Exerce aussi le chemin de code prod.
self::bootKernel();
$application = new Application(self::$kernel);
$application->setAutoExit(false);
$exit = $application->run(
new ArrayInput([
'command' => 'app:seed-rbac',
'--with-demo-users' => true,
'--password' => self::PWD,
]),
new NullOutput(),
);
self::assertSame(
0,
$exit,
'app:seed-rbac a echoue : les permissions technique.providers.* sont-elles synchronisees (app:sync-permissions) ?',
);
self::ensureKernelShutdown();
}
public function testBureauHasViewAndManageButNoAccountingNoArchive(): void
{
$seed = $this->seedProvider('Bureau Cible');
$client = $this->authAs('bureau');
// view
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// manage : creation OK (bypass_scope -> peut attacher le site 86)
$client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Bureau Cree'),
]);
self::assertResponseStatusCodeSame(201);
// manage : edition onglet principal OK
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Bureau Renomme'],
]);
self::assertResponseStatusCodeSame(200);
// PAS accounting : edition onglet Comptabilite refusee
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testBureauDetailHasNoAccountingFields(): void
{
// Bureau a view mais PAS accounting.view : les champs comptables sont
// ABSENTS du JSON (gating par omission, pas null).
$provider = $this->seedProvider('Bureau Gating Co', [self::SITE_86], siren: '123456789');
$client = $this->authAs('bureau');
$data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayNotHasKey('siren', $data);
self::assertArrayNotHasKey('accountNumber', $data);
self::assertArrayNotHasKey('nTva', $data);
self::assertArrayNotHasKey('tvaMode', $data);
self::assertArrayNotHasKey('paymentType', $data);
self::assertArrayNotHasKey('ribs', $data);
}
public function testComptaCanEditAccountingOnly(): void
{
$seed = $this->seedProvider('Compta Cible');
$client = $this->authAs('compta');
// view
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// PAS manage : creation refusee
$client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Compta Post'),
]);
self::assertResponseStatusCodeSame(403);
// accounting.manage : edition onglet Comptabilite OK
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(200);
// PAS manage : edition onglet principal refusee (mode strict RG-3.15)
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Compta Renomme'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testComptaDetailHasAccountingFields(): void
{
// Compta a accounting.view : siren + ribs presents dans le JSON.
$provider = $this->seedProvider('Compta View Co', [self::SITE_86], siren: '987654321');
$this->addRib($provider);
$client = $this->authAs('compta');
$data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('siren', $data);
self::assertSame('987654321', $data['siren']);
self::assertArrayHasKey('ribs', $data);
self::assertNotEmpty($data['ribs']);
}
public function testCommercialeHasViewAndManageButNoAccountingNoArchive(): void
{
$seed = $this->seedProvider('Commerciale Cible');
$client = $this->authAs('commerciale');
// view
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// manage : creation OK
$client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Commerciale Cree'),
]);
self::assertResponseStatusCodeSame(201);
// PAS accounting : edition onglet Comptabilite refusee
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testCommercialeDetailHasNoAccountingFields(): void
{
$provider = $this->seedProvider('Commerciale Gating Co', [self::SITE_86], siren: '123456789');
$client = $this->authAs('commerciale');
$data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayNotHasKey('siren', $data);
self::assertArrayNotHasKey('accountNumber', $data);
self::assertArrayNotHasKey('nTva', $data);
self::assertArrayNotHasKey('tvaMode', $data);
self::assertArrayNotHasKey('paymentType', $data);
self::assertArrayNotHasKey('ribs', $data);
}
public function testUsineHasReadOnlyAccessScopedToItsSite(): void
{
// Usine a view (lecture seule), SANS manage / accounting / archive, et
// SANS bypass_scope -> cloisonnee a son site courant (Chatellerault,
// site 86, pose par ensureDemoUsers).
$inScope = $this->seedProvider('Usine InScope', [self::SITE_86]);
$client = $this->authAs('usine');
// view : liste OK (pas un 403 comme au M2)
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// view : detail d'un prestataire de SON site OK
$client->request('GET', '/api/providers/'.$inScope->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// PAS manage : creation refusee
$client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Usine Post'),
]);
self::assertResponseStatusCodeSame(403);
// PAS manage : edition onglet principal refusee
$client->request('PATCH', '/api/providers/'.$inScope->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Renomme Par Usine'],
]);
self::assertResponseStatusCodeSame(403);
// PAS accounting : edition onglet Comptabilite refusee
$client->request('PATCH', '/api/providers/'.$inScope->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/providers/'.$inScope->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testUsineCannotSeeProviderOutOfItsSite(): void
{
// Cloisonnement § 2.13 : un prestataire hors du site courant de l'Usine
// (site 17, l'Usine est sur le site 86) -> 404 (ne pas reveler la ligne).
$outOfScope = $this->seedProvider('Usine OutOfScope', [self::SITE_17]);
$client = $this->authAs('usine');
$client->request('GET', '/api/providers/'.$outOfScope->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(404);
}
private function authAs(string $role): Client
{
return $this->authenticatedClient($role, self::PWD);
}
}
@@ -1,159 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use App\Module\Technique\Domain\Entity\Provider;
/**
* Tests du gating comptabilite + mode strict par groupe (ProviderProcessor /
* ProviderReadGroupContextBuilder) ERP-134.
*
* Couvre : gating accounting PAR OMISSION (siren/ribs absents sans accounting.view,
* bug #4 M1), mode strict RG-3.15 (403 sur tout le payload), gating archive (RG-3.13).
*
* Les users sont crees via createUserWithPermissions() (parent) : rattaches a TOUS
* les sites SANS currentSite -> CurrentSiteProvider::get() = null -> aucun
* cloisonnement, on isole ainsi le comportement RBAC du comportement site.
*
* @internal
*/
final class ProviderRbacGatingTest extends AbstractProviderApiTestCase
{
public function testAccountingFieldsOmittedWithoutAccountingView(): void
{
$provider = $this->seedProvider('Compta Masquee', [self::SITE_86], siren: '123456789');
$id = $provider->getId();
// Profil type Commerciale : view + manage SANS accounting.view.
$creds = $this->createUserWithPermissions(['technique.providers.view']);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]]);
self::assertSame(200, $response->getStatusCode());
$body = $response->toArray();
// Gating par omission : scalaires comptables ET ribs totalement absents.
self::assertArrayNotHasKey('siren', $body);
self::assertArrayNotHasKey('ribs', $body);
// isArchived reste expose (bug #3 M1 : la cle ne doit pas etre droppee).
self::assertArrayHasKey('isArchived', $body);
}
public function testAccountingFieldsPresentWithAccountingView(): void
{
$provider = $this->seedProvider('Compta Visible', [self::SITE_86], siren: '987654321');
$id = $provider->getId();
$creds = $this->createUserWithPermissions([
'technique.providers.view',
'technique.providers.accounting.view',
]);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]]);
self::assertSame(200, $response->getStatusCode());
$body = $response->toArray();
self::assertSame('987654321', $body['siren']);
// La cle ribs apparait (collection vide ici, mais presente).
self::assertArrayHasKey('ribs', $body);
}
public function testStrictModeRejectsMixedGroupsForManageOnlyUser(): void
{
$provider = $this->seedProvider('Strict Cible', [self::SITE_86]);
$id = $provider->getId();
// Profil type Bureau : manage SANS accounting.manage.
$creds = $this->createUserWithPermissions([
'technique.providers.view',
'technique.providers.manage',
]);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('PATCH', '/api/providers/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Renomme', 'siren' => '111222333'],
]);
// RG-3.15 : payload melangeant main + accounting sans accounting.manage
// -> 403 sur tout le payload (mode strict, pas de filtrage silencieux).
self::assertSame(403, $response->getStatusCode());
// Aucun champ n'a ete persiste (rollback du mode strict).
$this->getEm()->clear();
$reloaded = $this->getEm()->getRepository(Provider::class)->find($id);
self::assertSame('STRICT CIBLE', $reloaded->getCompanyName());
self::assertNull($reloaded->getSiren());
}
public function testAccountingOnlyUserCanPatchAccountingButNotMain(): void
{
$provider = $this->seedProvider('Compta Editrice', [self::SITE_86]);
$id = $provider->getId();
// Profil type Compta : accounting.view + accounting.manage SANS manage.
$creds = $this->createUserWithPermissions([
'technique.providers.view',
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
]);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
// PATCH accounting -> 200.
$ok = $client->request('PATCH', '/api/providers/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '555666777'],
]);
self::assertSame(200, $ok->getStatusCode());
// PATCH main (companyName) -> 403 (pas de permission manage).
$ko = $client->request('PATCH', '/api/providers/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Interdit'],
]);
self::assertSame(403, $ko->getStatusCode());
}
public function testArchiveRequiresArchivePermission(): void
{
$provider = $this->seedProvider('A Archiver', [self::SITE_86]);
$id = $provider->getId();
// Bureau (manage) sans archive -> 403.
$creds = $this->createUserWithPermissions([
'technique.providers.view',
'technique.providers.manage',
]);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('PATCH', '/api/providers/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
// RG-3.13 : l'archivage exige technique.providers.archive.
self::assertSame(403, $response->getStatusCode());
}
public function testAdminCanArchiveAndSetsArchivedAt(): void
{
$provider = $this->seedProvider('Archivable', [self::SITE_86]);
$id = $provider->getId();
$client = $this->createAdminClient();
$response = $client->request('PATCH', '/api/providers/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertSame(200, $response->getStatusCode());
$this->getEm()->clear();
$reloaded = $this->getEm()->getRepository(Provider::class)->find($id);
self::assertTrue($reloaded->isArchived());
self::assertNotNull($reloaded->getArchivedAt());
}
}
@@ -1,171 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
/**
* Tests du cloisonnement par site pilote par l'utilisateur (RG-3.17 / § 2.13)
* ERP-134. Couvre la LECTURE (liste filtree avant pagination + totalItems, detail
* 404 hors perimetre, bypass voit tout) et l'ECRITURE (sites hors user_site -> 422).
*
* Cloisonnement pilote par l'USER (pas le role) : on cree des users non-admin SANS
* `sites.bypass_scope`, rattaches a un site precis avec un currentSite. L'admin
* (isAdmin -> bypass total) sert de temoin « voit tout ».
*
* @internal
*/
final class ProviderSiteScopeTest extends AbstractProviderApiTestCase
{
protected function setUp(): void
{
parent::setUp();
// Pre-requis : le module Sites doit etre actif (sinon currentSite = null,
// cloisonnement no-op et ces tests perdent leur sens).
$this->skipIfSitesModuleDisabled();
}
public function testListIsScopedToCurrentSiteForNonBypassUser(): void
{
$this->seedProvider('Presta Site 86', [self::SITE_86]);
$this->seedProvider('Presta Site 17', [self::SITE_17]);
$this->seedProvider('Presta Site 82', [self::SITE_82]);
$creds = $this->createScopedUser(
['technique.providers.view'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
self::assertSame(200, $response->getStatusCode());
$body = $response->toArray();
// totalItems reflete le PERIMETRE de l'user (filtre avant pagination).
self::assertSame(1, $body['totalItems']);
self::assertSame('PRESTA SITE 86', $body['member'][0]['companyName']);
}
public function testDetailOutOfScopeReturns404(): void
{
$inScope = $this->seedProvider('Dans Perimetre', [self::SITE_86]);
$outOfScope = $this->seedProvider('Hors Perimetre', [self::SITE_17]);
$creds = $this->createScopedUser(
['technique.providers.view'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
// In-scope -> 200.
$ok = $client->request('GET', '/api/providers/'.$inScope->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertSame(200, $ok->getStatusCode());
// Out-of-scope -> 404 (ne pas reveler l'existence hors perimetre).
$ko = $client->request('GET', '/api/providers/'.$outOfScope->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertSame(404, $ko->getStatusCode());
}
public function testBypassUserSeesAllSites(): void
{
$this->seedProvider('Presta Site 86', [self::SITE_86]);
$this->seedProvider('Presta Site 17', [self::SITE_17]);
$this->seedProvider('Presta Site 82', [self::SITE_82]);
// Admin = bypass total.
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
self::assertSame(200, $response->getStatusCode());
self::assertSame(3, $response->toArray()['totalItems']);
}
public function testWriteOutOfScopeSiteRejectedAtIriResolution(): void
{
// User non-bypass / non-read_ref : la resolution de l'IRI du site hors
// perimetre echoue en amont (SiteCollectionScopedExtension : item Site
// « introuvable ») -> 400 anti-enumeration, avant le ProviderProcessor.
$creds = $this->createScopedUser(
['technique.providers.view', 'technique.providers.manage'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Hors Scope Sas', [self::SITE_17]),
]);
self::assertSame(400, $response->getStatusCode());
}
public function testWriteOutOfScopeSiteRejectedByProcessorGuard(): void
{
// User `sites.read_ref` : peut RESOUDRE n'importe quel site (referentiel
// transverse) mais n'opere que sur ses user_site. La garde guardSiteScope
// du ProviderProcessor est alors l'enforcement autoritaire de RG-3.17
// -> 422 sur `sites` (mappable inline, ERP-101).
$creds = $this->createScopedUser(
['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Hors Scope Guard', [self::SITE_17]),
]);
self::assertSame(422, $response->getStatusCode());
self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false)));
}
public function testWriteAllowsSiteWithinUserScope(): void
{
$creds = $this->createScopedUser(
['technique.providers.view', 'technique.providers.manage'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
// Site 86 = un des user_site -> 201.
$response = $client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Dans Scope Sas', [self::SITE_86]),
]);
self::assertSame(201, $response->getStatusCode());
}
public function testPatchAddingOutOfScopeSiteIsRejected(): void
{
$provider = $this->seedProvider('Patch Sites', [self::SITE_86]);
$id = $provider->getId();
// read_ref pour pouvoir resoudre l'IRI du site 17 (sinon 400 en amont) et
// exercer la garde guardSiteScope sur le PATCH.
$creds = $this->createScopedUser(
['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$site86 = $this->site(self::SITE_86)->getId();
$site17 = $this->site(self::SITE_17)->getId();
$response = $client->request('PATCH', '/api/providers/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['sites' => ['/api/sites/'.$site86, '/api/sites/'.$site17]],
]);
// RG-3.17 : ajouter un site hors user_site -> 422 (garde Processor).
self::assertSame(422, $response->getStatusCode());
self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false)));
}
}
@@ -1,392 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use App\Module\Technique\Domain\Entity\Provider;
/**
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire
* (M3, spec § 4.5 ERP-135). Couvrent : normalisation contact (RG-3.11), RG-3.04
* (au moins un champ parmi prenom/nom/telephone/email), RG-3.05 (>= 1 site sur
* l'adresse), RG-3.06 (code postal), RG-3.09 (categorie PRESTATAIRE sur adresse),
* le cloisonnement d'ecriture des sites de l'adresse (§ 2.13 -> 422 sur `sites`),
* RG-3.08 (DELETE dernier RIB sous LCR -> 409), DELETE contact libre au M3 (pas de
* garde « dernier contact ») et le gating selon permission (Contacts/Adresses =
* manage, RIB = accounting.manage). Jumeau de SupplierSubResourceApiTest.
*
* @internal
*/
final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
{
protected function setUp(): void
{
parent::setUp();
// seedProvider exige >= 1 site (RG-3.03) : le module Sites doit etre actif.
$this->skipIfSitesModuleDisabled();
}
// === Contacts (security: technique.providers.manage) ===
public function testPostContactNormalizesFields(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact Host');
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'firstName' => 'JEAN',
'lastName' => 'dupont',
'phonePrimary' => '06.12.34.56.78',
'email' => 'Jean.DUPONT@ACME.FR',
],
])->toArray();
self::assertResponseStatusCodeSame(201);
// RG-3.11 : prenom/nom Title Case, telephone chiffres seuls, email lowercase.
self::assertSame('Jean', $data['firstName']);
self::assertSame('Dupont', $data['lastName']);
self::assertSame('0612345678', $data['phonePrimary']);
self::assertSame('jean.dupont@acme.fr', $data['email']);
}
/**
* RG-3.04 : un bloc sans aucun champ du CHECK (prenom/nom/telephone/email) est
* rejete avant la base (chk_provider_contact_name) -> 422 rattachee a firstName.
* Ici seul jobTitle est fourni (hors CHECK).
*/
public function testPostContactWithoutNamedFieldReturns422OnFirstNamePath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact No Name');
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['jobTitle' => 'Directeur'],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false)));
}
public function testPostContactOnMissingProviderReturns404(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/providers/999999/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['firstName' => 'Orphan'],
]);
self::assertResponseStatusCodeSame(404);
}
public function testPatchContactNormalizesFields(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact Patch');
$contact = $this->addContact($seed, 'Marie', 'Martin');
$data = $client->request('PATCH', '/api/provider_contacts/'.$contact->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['lastName' => 'durand'],
])->toArray();
self::assertResponseStatusCodeSame(200);
// Normalisation aussi sur PATCH : "durand" -> "Durand".
self::assertSame('Durand', $data['lastName']);
}
public function testDeleteLastContactReturns204(): void
{
// M3 : pas de garde « dernier contact » (RG-3.12 front-driven) — la
// suppression du dernier contact est libre (204).
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact Solo');
$contact = $this->addContact($seed, 'Unique', 'Contact');
$client->request('DELETE', '/api/provider_contacts/'.$contact->getId());
self::assertResponseStatusCodeSame(204);
}
public function testContactWriteWithoutManageReturns403(): void
{
// Un user sans permission technique.providers.manage -> 403 sur la sous-ressource.
$seed = $this->seedProvider('Contact Forbidden');
$creds = $this->createUserWithPermission('core.users.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$http->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => ['firstName' => 'Nope'],
]);
self::assertResponseStatusCodeSame(403);
}
// === Adresses (security: technique.providers.manage) ===
public function testPostAddressWithValidPayloadReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address Host');
$category = $this->providerCategory('NETTOYAGE');
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('Châtellerault', $data['city']);
}
public function testPostAddressWithoutSiteReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address No Site');
$client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [],
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
],
]);
// RG-3.05 (Assert\Count min 1 sur sites).
self::assertResponseStatusCodeSame(422);
}
public function testPostAddressWithInvalidPostalCodeReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address Bad CP');
$client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '123',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
],
]);
// RG-3.06 (Assert\Regex ^[0-9]{4,5}$).
self::assertResponseStatusCodeSame(422);
}
public function testPostAddressWithNonPrestataireCategoryReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address Bad Cat');
$foreign = $this->foreignCategory(); // type CLIENT -> interdite (RG-3.09).
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
'categories' => ['/api/categories/'.$foreign->getId()],
],
]);
// RG-3.09 -> 422 rattachee a categories.
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
}
public function testDeleteAddressReturns204(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address Delete');
$category = $this->providerCategory('NETTOYAGE');
$created = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray();
$client->request('DELETE', $created['@id']);
self::assertResponseStatusCodeSame(204);
}
public function testAddressWriteWithoutManageReturns403(): void
{
$seed = $this->seedProvider('Address Forbidden');
$creds = $this->createUserWithPermission('core.users.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$http->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
],
]);
self::assertResponseStatusCodeSame(403);
}
/**
* § 2.13 / RG-3.05 (cloisonnement d'ECRITURE sur l'adresse) : un user non-bypass
* `sites.read_ref` (qui peut resoudre n'importe quel IRI de site, sinon 400 en
* amont) ne peut attacher a l'adresse que ses propres user_site. Site hors
* perimetre -> 422 sur `sites` (garde ProviderAddressProcessor).
*/
public function testPostAddressWithOutOfScopeSiteReturns422OnSitesPath(): void
{
$seed = $this->seedProvider('Address Scope', [self::SITE_86]);
$category = $this->providerCategory('NETTOYAGE');
$creds = $this->createScopedUser(
['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'postalCode' => '17400',
'city' => 'Saint-Jean-d\'Angély',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_17)->getId()], // hors user_site
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false)));
}
// === RIBs (security: technique.providers.accounting.manage) ===
public function testPostRibByAdminReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib Host');
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'label' => 'Compte principal',
'bic' => self::VALID_BIC,
'iban' => self::VALID_IBAN,
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('Compte principal', $data['label']);
}
public function testPostRibWithInvalidIbanReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib Bad Iban');
$client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => ['label' => 'Compte invalide', 'bic' => self::VALID_BIC, 'iban' => 'INVALID-IBAN'],
]);
self::assertResponseStatusCodeSame(422);
}
/**
* Controle croise pays BIC/IBAN (Assert\Bic ibanPropertyPath) : un BIC (DE) et
* un IBAN (FR) valides isolement mais de pays differents -> 422 sur `bic`.
*/
public function testPostRibWithBicIbanCountryMismatchReturns422OnBic(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib Pays Mismatch');
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['label' => 'Compte incoherent', 'bic' => self::FOREIGN_BIC, 'iban' => self::VALID_IBAN],
]);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('bic', $byPath);
self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']);
}
public function testDeleteRibNonLcrReturns204(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib Non LCR');
$rib = $this->addRib($seed);
$client->request('DELETE', '/api/provider_ribs/'.$rib->getId());
self::assertResponseStatusCodeSame(204);
}
public function testDeleteLastRibUnderLcrReturns409(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib LCR Solo');
$rib = $this->addRib($seed);
// Passe le prestataire en LCR (seed direct).
$em = $this->getEm();
$managed = $em->getRepository(Provider::class)->find($seed->getId());
$managed->setPaymentType($this->paymentType('LCR'));
$em->flush();
$client->request('DELETE', '/api/provider_ribs/'.$rib->getId());
// RG-3.08 : LCR exige >= 1 RIB -> suppression du dernier refusee.
self::assertResponseStatusCodeSame(409);
}
public function testRibWriteWithoutAccountingManageReturns403(): void
{
// Un user portant seulement technique.providers.manage (sans accounting.manage)
// ne peut ni creer, ni modifier, ni supprimer un RIB (gating renforce § 4.5).
$seed = $this->seedProvider('Rib Forbidden');
$rib = $this->addRib($seed);
$creds = $this->createUserWithPermission('technique.providers.manage');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$http->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => ['label' => 'X', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN],
]);
self::assertResponseStatusCodeSame(403);
$http->request('PATCH', '/api/provider_ribs/'.$rib->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['label' => 'Y'],
]);
self::assertResponseStatusCodeSame(403);
$http->request('DELETE', '/api/provider_ribs/'.$rib->getId());
self::assertResponseStatusCodeSame(403);
}
}
@@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique;
use App\Module\Technique\TechniqueModule;
use PHPUnit\Framework\TestCase;
/**
* Tests structurels du module Technique (M3) : identite et contrat
* `permissions()`.
*
* @internal
*/
final class TechniqueModuleTest extends TestCase
{
public function testModuleIdentity(): void
{
self::assertSame('technique', TechniqueModule::ID);
self::assertSame('Technique', TechniqueModule::LABEL);
self::assertFalse(TechniqueModule::REQUIRED);
}
public function testPermissionsSetContainsExactlyFiveCodes(): void
{
// Garde-fou : le jeu de permissions du module est fige par ce test. Si
// quelqu'un ajoute / retire une permission sans ajuster la spec (§ 5.1)
// ni la matrice RBAC, le test casse explicitement.
$codes = array_column(TechniqueModule::permissions(), 'code');
sort($codes);
self::assertSame(
[
'technique.providers.accounting.manage',
'technique.providers.accounting.view',
'technique.providers.archive',
'technique.providers.manage',
'technique.providers.view',
],
$codes,
);
}
public function testEveryPermissionCodeIsPrefixedByModuleId(): void
{
// Convention de nommage `module.resource[.sub].action` : le prefixe doit
// correspondre exactement a l'ID du module (verifie aussi par
// app:sync-permissions).
foreach (TechniqueModule::permissions() as $permission) {
self::assertStringStartsWith(
TechniqueModule::ID.'.',
$permission['code'],
'Chaque code de permission doit etre prefixe par l\'ID du module.',
);
self::assertNotSame('', $permission['label'], 'Chaque permission doit porter un label.');
}
}
}
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Application\Idtf;
use App\Module\Transport\Application\Idtf\IdtfSheetParser;
use PHPUnit\Framework\TestCase;
use RuntimeException;
/**
* @internal
*/
final class IdtfSheetParserTest extends TestCase
{
public function testExtractsExportDate(): void
{
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
self::assertSame('2026-06-12', $parsed['exportDate']);
}
public function testParsesAndNormalizesFirstRow(): void
{
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
$row = $parsed['rows'][0];
self::assertSame(30748, $row['idtf_number']);
self::assertSame('Argiles avec régime de nettoyage C', $row['name']);
self::assertSame('C', $row['cleaning_regime']);
self::assertSame('2026-04-02', $row['mandatory_date']);
self::assertSame('Al2O3', $row['formula']);
self::assertSame('01 01 01', $row['eural_code']);
self::assertSame(['7631-86-9', '1344-28-1'], $row['cas_numbers']);
self::assertSame('Note 1', $row['footnotes']);
}
public function testSkipsEmptyAndNonNumericRows(): void
{
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
// 2 lignes exploitables (30748 et 30744) ; vide + "abc" ignorees.
self::assertCount(2, $parsed['rows']);
self::assertSame(30744, $parsed['rows'][1]['idtf_number']);
}
public function testEmptyOptionalCellsBecomeNullAndCasEmpty(): void
{
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
$row = $parsed['rows'][1]; // 30744
self::assertNull($row['mandatory_date']);
self::assertNull($row['formula']);
self::assertNull($row['product_group']);
self::assertSame([], $row['cas_numbers']);
}
public function testColumnOrderIsResolvedByLabel(): void
{
// En-tete dans un ordre different : le mapping doit suivre les libelles.
$matrix = [
['Export date: 1-1-2026'],
['Numéro CAS', 'Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'],
['7440-44-0', '99', 'Carbone', 'B'],
];
$parsed = IdtfSheetParser::parse($matrix);
$row = $parsed['rows'][0];
self::assertSame(99, $row['idtf_number']);
self::assertSame('Carbone', $row['name']);
self::assertSame('B', $row['cleaning_regime']);
self::assertSame(['7440-44-0'], $row['cas_numbers']);
}
public function testThrowsWhenHeaderMissing(): void
{
$this->expectException(RuntimeException::class);
IdtfSheetParser::parse([['foo', 'bar'], ['1', '2']]);
}
public function testExportDateNullWhenAbsent(): void
{
$matrix = [
['Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'],
['1', 'X', 'A'],
];
self::assertNull(IdtfSheetParser::parse($matrix)['exportDate']);
}
/**
* Matrice representative de l'export reel : preambule (lignes 0-1), ligne
* vide (2), en-tete (3) puis donnees.
*
* @return array<int, array<int, mixed>>
*/
private function sampleMatrix(): array
{
return [
['Export date: 12-6-2026'],
['Changes in the database after this date...'],
[],
['Numéro IDTF', 'Product Group', 'Nom de la marchandise', 'Régime de nettoyage', 'Exigences importantes', 'Date dapplication obligatoire', 'Produits apparentés', 'Formule', 'Code EURAL', 'Numéro CAS', 'Annotations'],
['30748', 'Substances inorganiques', 'Argiles avec régime de nettoyage C', 'C', 'Exigence X', '02-04-2026', 'Poudre argile', 'Al2O3', '01 01 01', '7631-86-9 ; 1344-28-1', 'Note 1'],
['', '', '', '', '', '', '', '', '', '', ''],
['abc', 'ligne non numerique a ignorer', '', '', '', '', '', '', '', '', ''],
['30744', '', 'Additifs alimentaires', 'A', '', '', '', '', '', '', ''],
];
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Application\Qualimat;
use App\Module\Transport\Application\Qualimat\QualimatRowMapper;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class QualimatRowMapperTest extends TestCase
{
public function testNormalizeSiretStripsNonDigits(): void
{
self::assertSame('44415628500025', QualimatRowMapper::normalizeSiret('444 156 285 000 25'));
self::assertNull(QualimatRowMapper::normalizeSiret(null));
self::assertNull(QualimatRowMapper::normalizeSiret(' '));
self::assertNull(QualimatRowMapper::normalizeSiret(''));
}
public function testParseDate(): void
{
self::assertSame('2027-05-14', QualimatRowMapper::parseDate('14/05/2027'));
self::assertNull(QualimatRowMapper::parseDate(null));
self::assertNull(QualimatRowMapper::parseDate('2027-05-14'));
self::assertNull(QualimatRowMapper::parseDate('14-05-2027'));
// Date calendaire impossible : evite un INSERT en erreur.
self::assertNull(QualimatRowMapper::parseDate('31/02/2027'));
}
public function testMapOneNormalizesAndTrims(): void
{
$row = QualimatRowMapper::mapOne([
'Nom' => ' 2C TRANS ',
'Societe' => '2C TRANS',
'Adresse' => '66 Impasse Mendi',
'CodePostal' => '65500',
'Ville' => 'VIC EN BIGORRE',
'Telephone_1' => '+33|0608890316',
'Siret' => '444 156 285 000 25',
'Validite' => '14/05/2027',
'Statut' => 'Audité',
'Departement' => '65 - Hautes-Pyrénées',
]);
self::assertNotNull($row);
self::assertSame('44415628500025', $row['siret']);
self::assertSame('2C TRANS', $row['name']);
self::assertSame('2027-05-14', $row['validity_date']);
self::assertSame('+33|0608890316', $row['phone']);
self::assertSame('Audité', $row['status']);
self::assertSame('65 - Hautes-Pyrénées', $row['department']);
}
public function testMapOneReturnsNullWithoutSiret(): void
{
self::assertNull(QualimatRowMapper::mapOne(['Nom' => 'X', 'Siret' => null]));
self::assertNull(QualimatRowMapper::mapOne(['Nom' => 'X']));
self::assertNull(QualimatRowMapper::mapOne(['Nom' => 'X', 'Siret' => ' ']));
}
public function testMapManyCountsSkipped(): void
{
$result = QualimatRowMapper::mapMany([
['Nom' => 'A', 'Siret' => '111 111 111 00011', 'Statut' => 'Audité', 'Validite' => '01/01/2030'],
['Nom' => 'B', 'Siret' => null],
['Nom' => 'C', 'Siret' => ' '],
]);
self::assertCount(1, $result['rows']);
self::assertSame(2, $result['skipped']);
}
public function testEmptyOptionalFieldsBecomeNull(): void
{
$row = QualimatRowMapper::mapOne([
'Siret' => '111 111 111 00011',
'Nom' => 'A',
'Adresse' => '',
'Ville' => ' ',
]);
self::assertNotNull($row);
self::assertNull($row['address']);
self::assertNull($row['city']);
self::assertNull($row['validity_date']);
}
}
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Infrastructure\Console;
use Doctrine\DBAL\Connection;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
/**
* Test fonctionnel de `app:idtf:sync` via --file : genere un vrai .xlsx, le
* passe a la commande et verifie le parsing, l'upsert, le journal et le
* soft-delete (chemin complet IOFactory -> parser -> DBAL).
*
* @internal
*/
final class SyncIdtfCommandTest extends KernelTestCase
{
private Connection $connection;
protected function setUp(): void
{
self::bootKernel();
/** @var Connection $connection */
$connection = self::getContainer()->get('doctrine.dbal.default_connection');
$this->connection = $connection;
$this->purge();
}
protected function tearDown(): void
{
$this->purge();
parent::tearDown();
}
public function testSyncParsesXlsxUpsertsAndLogs(): void
{
$path = $this->makeXlsx([
['Export date: 12-6-2026'],
['Avertissement preambule'],
[],
['Numéro IDTF', 'Product Group', 'Nom de la marchandise', 'Régime de nettoyage', 'Exigences importantes', 'Date dapplication obligatoire', 'Produits apparentés', 'Formule', 'Code EURAL', 'Numéro CAS', 'Annotations'],
['30748', 'Inorganiques', 'Argiles régime C', 'C', 'Exig X', '02-04-2026', 'Poudre', 'Al2O3', '01 01 01', '7631-86-9 ; 1344-28-1', 'Note'],
['', '', '', '', '', '', '', '', '', '', ''],
['30744', '', 'Additifs', 'A', '', '', '', '', '', '', ''],
]);
$tester = $this->runSync($path);
$tester->assertCommandIsSuccessful();
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM idtf_product'));
$row = $this->connection->fetchAssociative("SELECT * FROM idtf_product WHERE idtf_number = 30748 AND schema = 'road'");
self::assertNotFalse($row);
self::assertSame('Argiles régime C', $row['name']);
self::assertSame('C', $row['cleaning_regime']);
self::assertSame('2026-04-02', $row['mandatory_date']);
self::assertSame('2026-06-12', $row['source_export_date']);
self::assertSame(['7631-86-9', '1344-28-1'], json_decode((string) $row['cas_numbers'], true));
$log = $this->connection->fetchAssociative('SELECT * FROM idtf_sync_log ORDER BY id DESC LIMIT 1');
self::assertNotFalse($log);
self::assertSame('road', $log['schema']);
self::assertSame('2026-06-12', $log['export_date']);
self::assertSame(2, (int) $log['rows_total']);
self::assertSame(2, (int) $log['rows_upserted']);
self::assertSame(0, (int) $log['rows_deactivated']);
}
public function testSecondSyncSoftDeletesMissing(): void
{
$header = ['Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'];
$this->runSync($this->makeXlsx([
['Export date: 1-6-2026'],
$header,
['100', 'Produit 100', 'A'],
['200', 'Produit 200', 'B'],
]))->assertCommandIsSuccessful();
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM idtf_product WHERE is_active = TRUE'));
// 2e export sans 200 -> soft-delete de 200, mise a jour de 100.
$tester = $this->runSync($this->makeXlsx([
['Export date: 2-6-2026'],
$header,
['100', 'Produit 100 maj', 'C'],
]));
$tester->assertCommandIsSuccessful();
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM idtf_product'));
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM idtf_product WHERE is_active = TRUE'));
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM idtf_product WHERE idtf_number = 200 AND is_active = FALSE'));
$row100 = $this->connection->fetchAssociative('SELECT * FROM idtf_product WHERE idtf_number = 100');
self::assertNotFalse($row100);
self::assertSame('Produit 100 maj', $row100['name']);
self::assertSame('C', $row100['cleaning_regime']);
$log = $this->connection->fetchAssociative('SELECT * FROM idtf_sync_log ORDER BY id DESC LIMIT 1');
self::assertNotFalse($log);
self::assertSame(1, (int) $log['rows_upserted']);
self::assertSame(1, (int) $log['rows_deactivated']);
}
public function testInvalidSchemaIsRejected(): void
{
$path = $this->makeXlsx([
['Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'],
['1', 'X', 'A'],
]);
$application = new Application(self::$kernel);
$tester = new CommandTester($application->find('app:idtf:sync'));
$exitCode = $tester->execute(['--file' => $path, '--schema' => 'air']);
@unlink($path);
self::assertSame(2, $exitCode); // Command::INVALID
self::assertSame(0, $this->countRows('SELECT COUNT(*) FROM idtf_product'));
}
/**
* @param array<int, array<int, mixed>> $matrix
*/
private function makeXlsx(array $matrix): string
{
$spreadsheet = new Spreadsheet();
$spreadsheet->getActiveSheet()->fromArray($matrix, null, 'A1', true);
$path = tempnam(sys_get_temp_dir(), 'idtf_').'.xlsx';
new Xlsx($spreadsheet)->save($path);
$spreadsheet->disconnectWorksheets();
return $path;
}
private function runSync(string $path): CommandTester
{
$application = new Application(self::$kernel);
$tester = new CommandTester($application->find('app:idtf:sync'));
$tester->execute(['--file' => $path, '--schema' => 'road']);
@unlink($path);
return $tester;
}
private function countRows(string $sql): int
{
return (int) $this->connection->fetchOne($sql);
}
private function purge(): void
{
$this->connection->executeStatement('DELETE FROM idtf_product');
$this->connection->executeStatement('DELETE FROM idtf_sync_log');
}
}
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Infrastructure\Console;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
use const JSON_THROW_ON_ERROR;
/**
* Test fonctionnel de `app:qualimat:sync` via l'option --file (pas d'appel
* reseau) : verifie l'upsert normalise, le journal et le soft-delete.
*
* @internal
*/
final class SyncQualimatCommandTest extends KernelTestCase
{
private Connection $connection;
protected function setUp(): void
{
self::bootKernel();
/** @var Connection $connection */
$connection = self::getContainer()->get('doctrine.dbal.default_connection');
$this->connection = $connection;
$this->purge();
}
protected function tearDown(): void
{
$this->purge();
parent::tearDown();
}
public function testFirstSyncInsertsNormalizesAndLogs(): void
{
$tester = $this->runSync([
[
'Nom' => '2C TRANS',
'Societe' => '2C TRANS',
'Adresse' => '66 Impasse Mendi',
'CodePostal' => '65500',
'Ville' => 'VIC EN BIGORRE',
'Telephone_1' => '+33|0608890316',
'Siret' => '444 156 285 000 25',
'Validite' => '14/05/2027',
'Statut' => 'Audité',
'Departement' => '65 - Hautes-Pyrénées',
],
// Item sans SIRET : doit etre ignore (compte dans rows_skipped).
['Nom' => 'SANS SIRET', 'Siret' => null, 'Validite' => '01/01/2030', 'Statut' => 'Valide'],
]);
$tester->assertCommandIsSuccessful();
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier'));
$row = $this->connection->fetchAssociative('SELECT * FROM qualimat_carrier');
self::assertNotFalse($row);
self::assertSame('44415628500025', $row['siret']);
self::assertSame('2C TRANS', $row['name']);
self::assertSame('2027-05-14', $row['validity_date']);
self::assertSame('+33|0608890316', $row['phone']);
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier WHERE is_active = TRUE'));
$log = $this->connection->fetchAssociative('SELECT * FROM qualimat_sync_log ORDER BY id DESC LIMIT 1');
self::assertNotFalse($log);
self::assertSame(2, (int) $log['rows_total']);
self::assertSame(1, (int) $log['rows_upserted']);
self::assertSame(1, (int) $log['rows_skipped']);
self::assertSame(0, (int) $log['rows_deactivated']);
}
public function testSecondSyncUpdatesAndSoftDeletesMissing(): void
{
$a = ['Nom' => 'A', 'Siret' => '111 111 111 00011', 'Validite' => '01/01/2030', 'Statut' => 'Audité'];
$b = ['Nom' => 'B', 'Siret' => '222 222 222 00022', 'Validite' => '01/01/2030', 'Statut' => 'Audité'];
$this->runSync([$a, $b])->assertCommandIsSuccessful();
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier WHERE is_active = TRUE'));
// 2e run sans B et avec A renomme : A est mis a jour, B est soft-delete.
$aRenamed = ['Nom' => 'A BIS', 'Siret' => '111 111 111 00011', 'Validite' => '02/02/2031', 'Statut' => 'Valide'];
$tester = $this->runSync([$aRenamed]);
$tester->assertCommandIsSuccessful();
// Toujours 2 lignes en base, mais une seule active.
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier'));
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier WHERE is_active = TRUE'));
self::assertSame(1, $this->countRows("SELECT COUNT(*) FROM qualimat_carrier WHERE siret = '22222222200022' AND is_active = FALSE"));
// A a bien ete mis a jour (nom + statut + date).
$a = $this->connection->fetchAssociative("SELECT * FROM qualimat_carrier WHERE siret = '11111111100011'");
self::assertNotFalse($a);
self::assertSame('A BIS', $a['name']);
self::assertSame('Valide', $a['status']);
self::assertSame('2031-02-02', $a['validity_date']);
$log = $this->connection->fetchAssociative('SELECT * FROM qualimat_sync_log ORDER BY id DESC LIMIT 1');
self::assertNotFalse($log);
self::assertSame(1, (int) $log['rows_upserted']);
self::assertSame(1, (int) $log['rows_deactivated']);
self::assertSame(0, (int) $log['rows_skipped']);
}
/**
* @param array<int, array<string, mixed>> $items
*/
private function runSync(array $items): CommandTester
{
$path = tempnam(sys_get_temp_dir(), 'qualimat_').'.json';
file_put_contents($path, json_encode($items, JSON_THROW_ON_ERROR));
$application = new Application(self::$kernel);
$tester = new CommandTester($application->find('app:qualimat:sync'));
$tester->execute(['--file' => $path]);
@unlink($path);
return $tester;
}
private function countRows(string $sql): int
{
return (int) $this->connection->fetchOne($sql);
}
private function purge(): void
{
$this->connection->executeStatement('DELETE FROM qualimat_carrier');
$this->connection->executeStatement('DELETE FROM qualimat_sync_log');
}
}