diff --git a/docs/specs/M3-prestataires/spec-back.md b/docs/specs/M3-prestataires/spec-back.md index b9a1dbd..54a573a 100644 --- a/docs/specs/M3-prestataires/spec-back.md +++ b/docs/specs/M3-prestataires/spec-back.md @@ -624,67 +624,153 @@ Même pattern que les jumelles `Supplier*` (`#[Auditable]`, `TimestampableBlamab > 1. Réfs comptables (`tvaMode`/`paymentDelay`/`paymentType`/`bank`) : doivent sortir en **objet `{id, code, label}`**, pas en IRI nu → vérifier que les entités partagées portent bien le groupe `provider:read:accounting` (sinon les annoter, comme le fix ERP-92 l'a fait pour `supplier:read:accounting`). > 2. Gating compta par **omission de clé** : pour un user sans `accounting.view`, les clés `siren`/`tvaMode`/`ribs`/… sont **absentes** (pas `null`). -`GET /api/providers` (liste, ADMIN — un membre, forme attendue) : +> **✅ Capturé sur l'API réelle (ERP-139)** via `ProviderSerializationContractTest::testDodReferenceJsonShape` (`PROVIDER_DOD_DUMP=1`). Les `id`/`companyName`/noms de catégorie ci-dessous proviennent du prestataire seedé par le test (`seedCompleteProvider`) ; la **forme** (clés, embed, gating) est le contrat réel à respecter côté front. + +`GET /api/providers` (liste, ADMIN avec `accounting.view` — un membre, capture réelle) : ```json { - "@context": "/api/contexts/Provider", - "@id": "/api/providers", - "@type": "Collection", - "totalItems": 1, - "member": [ - { - "@id": "/api/providers/1", "@type": "Provider", "id": 1, - "companyName": "MAINTENANCE PRO SAS", - "categories": [ - {"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE", - "categoryType": {"@id": "/api/category_types/3", "@type": "CategoryType", "id": 3, "code": "PRESTATAIRE", "label": "Prestataire"}} - ], - "sites": [ - {"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"} - ], - "siren": "987654321", "accountNumber": "P0001", - "tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"}, - "paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"}, - "ribs": [ - {"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"} - ], - "updatedAt": "2026-06-11T10:00:00+02:00", - "isArchived": false - } - ], - "view": {"@id": "/api/providers", "@type": "PartialCollectionView"} + "@context": "/api/contexts/Provider", + "@id": "/api/providers", + "@type": "Collection", + "totalItems": 1, + "member": [ + { + "@id": "/api/providers/572", + "@type": "Provider", + "id": 572, + "companyName": "DOD21AADC 0E3CCE", + "categories": [ + { + "@type": "Category", + "@id": "/api/categories/3006", + "id": 3006, + "name": "test_prov_cat_nettoyage", + "code": "NETTOYAGE", + "categoryTypes": [ + {"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"} + ], + "createdAt": "2026-06-12T15:17:29+02:00", + "updatedAt": "2026-06-12T15:17:29+02:00" + } + ], + "sites": [ + {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"}, + {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"} + ], + "siren": "987654321", + "accountNumber": "P0001", + "tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"}, + "nTva": "FR00987654321", + "paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"}, + "paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"}, + "ribs": [ + {"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "createdAt": "2026-06-12T15:17:29+02:00", + "updatedAt": "2026-06-12T15:17:29+02:00", + "isArchived": false + } + ], + "view": {"@id": "/api/providers?search=DoD21aadc", "@type": "PartialCollectionView"} } ``` -> Les prestataires archivés sont **exclus** du `totalItems` (RG-3.16). Pour la **Commerciale** (sans `accounting.view`), `siren`/`tvaMode`/`paymentType`/`ribs`… **disparaissent** de chaque membre. +> Les `sites[]` de la liste sont la **relation directe** `provider.sites` (formulaire principal — RG-3.03), objet `Site` entier (pas un IRI nu). Les catégories embarquent `code` + `name`. Les prestataires archivés sont **exclus** du `totalItems` (RG-3.16). Pour un profil **sans `accounting.view`** (ex. Commerciale), `siren`/`accountNumber`/`tvaMode`/`nTva`/`paymentDelay`/`paymentType`/`bank`/`ribs` **disparaissent** de chaque membre (gating par omission — cf. détail restreint ci-dessous). -`GET /api/providers/{id}` (détail — user avec `accounting.view`, forme attendue) : +`GET /api/providers/{id}` (détail — user **avec** `accounting.view`, capture réelle) : ```json { - "@id": "/api/providers/1", "@type": "Provider", "id": 1, - "companyName": "MAINTENANCE PRO SAS", - "categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}], - "sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}], - "siren": "987654321", "accountNumber": "P0001", - "tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"}, - "nTva": "FR00987654321", - "paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"}, - "paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"}, - "contacts": [ - {"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"} - ], - "addresses": [ - {"@id": "/api/provider_addresses/1", "@type": "ProviderAddress", "id": 1, "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias", - "sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}], - "contacts": [{"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin"}], - "categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}]} - ], - "ribs": [{"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}], - "isArchived": false + "@context": "/api/contexts/Provider", + "@id": "/api/providers/572", + "@type": "Provider", + "id": 572, + "companyName": "DOD21AADC 0E3CCE", + "categories": [ + {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "sites": [ + {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"}, + {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"} + ], + "siren": "987654321", + "accountNumber": "P0001", + "tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"}, + "nTva": "FR00987654321", + "paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"}, + "paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"}, + "contacts": [ + {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "addresses": [ + { + "@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35, + "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias", + "sites": [ + {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"}, + {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"} + ], + "contacts": [ + {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "categories": [ + {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00" + } + ], + "ribs": [ + {"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "createdAt": "2026-06-12T15:17:29+02:00", + "updatedAt": "2026-06-12T15:17:29+02:00", + "isArchived": false } ``` -> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (gating par omission — à confirmer par test). +`GET /api/providers/{id}` (même prestataire, user **sans** `accounting.view` — capture réelle) : +```json +{ + "@context": "/api/contexts/Provider", + "@id": "/api/providers/572", + "@type": "Provider", + "id": 572, + "companyName": "DOD21AADC 0E3CCE", + "categories": [ + {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "sites": [ + {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"}, + {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"} + ], + "contacts": [ + {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "addresses": [ + { + "@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35, + "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias", + "sites": [ + {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"}, + {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"} + ], + "contacts": [ + {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "categories": [ + {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00" + } + ], + "createdAt": "2026-06-12T15:17:29+02:00", + "updatedAt": "2026-06-12T15:17:29+02:00", + "isArchived": false +} +``` + +> **Gating par omission confirmé sur le JSON réel** : pour un user **sans** `accounting.view`, les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank` **et `ribs`** sont **absentes** (pas `null`). `isArchived`, `contacts[]`, `addresses[]` (avec `sites[]`/`contacts[]`/`categories[]`) restent exposés. Vérifié par `ProviderSerializationContractTest::testRibsAbsentForUserWithoutAccountingView` + `testAccountingScalarsGatedByOmission`. +> +> **Réfs comptables = objets embarqués `{id, code, label}`** (pas IRI nu) : le fix ERP-139 a ajouté `provider:read:accounting` sur `TvaMode`/`PaymentDelay`/`PaymentType`/`Bank` (réplique du fix ERP-92 du M2). Vérifié par `testAccountingReferentialsEmbedIdCodeLabel`. ### 4.1 `GET /api/providers` — Liste @@ -923,7 +1009,7 @@ Cf. § 2.9 (matrice détaillée — identique à la matrice M2 transposée sur ` - [x] 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0) - [x] Décision embed vs GetCollection explicite et câblée (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5), **pas de POST-only** -- [ ] **Réponses JSON RÉELLES** à capturer (§ 4.0.bis) — gabarit posé, capture à faire au 1er ticket back (DoD avant front) +- [x] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — liste + détail (avec/sans `accounting.view`) collés depuis `ProviderSerializationContractTest` (ERP-139) - [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-3.15) - [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit + i18n, routes à plat : rappelés - [x] Réutilisations M1/M2 identifiées (référentiels compta partagés, taxonomie code/type, filtre `?typeCode=`, `usePaginatedList`, blocs, archive, normalisation, `useAddressAutocomplete`) diff --git a/src/Module/Commercial/Domain/Entity/Bank.php b/src/Module/Commercial/Domain/Entity/Bank.php index 1ee4542..1108cdd 100644 --- a/src/Module/Commercial/Domain/Entity/Bank.php +++ b/src/Module/Commercial/Domain/Entity/Bank.php @@ -21,7 +21,8 @@ use Symfony\Component\Serializer\Attribute\Groups; * Timestampable/Blamable (referentiel statique whiteliste dans * EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe * `client:read:accounting` permet l'embarquement dans la reponse Client ; - * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0). + * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ; + * `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis). */ #[ApiResource( operations: [ @@ -48,15 +49,15 @@ class Bank #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?int $id = null; #[ORM\Column(length: 30)] - #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $code = null; #[ORM\Column(length: 120)] - #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $label = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Commercial/Domain/Entity/PaymentDelay.php b/src/Module/Commercial/Domain/Entity/PaymentDelay.php index 5e8be75..94f55af 100644 --- a/src/Module/Commercial/Domain/Entity/PaymentDelay.php +++ b/src/Module/Commercial/Domain/Entity/PaymentDelay.php @@ -21,7 +21,8 @@ use Symfony\Component\Serializer\Attribute\Groups; * Timestampable/Blamable (referentiel statique whiteliste dans * EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe * `client:read:accounting` permet l'embarquement dans la reponse Client ; - * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0). + * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ; + * `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis). */ #[ApiResource( operations: [ @@ -48,15 +49,15 @@ class PaymentDelay #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?int $id = null; #[ORM\Column(length: 30)] - #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $code = null; #[ORM\Column(length: 120)] - #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $label = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Commercial/Domain/Entity/PaymentType.php b/src/Module/Commercial/Domain/Entity/PaymentType.php index af045c9..564b31a 100644 --- a/src/Module/Commercial/Domain/Entity/PaymentType.php +++ b/src/Module/Commercial/Domain/Entity/PaymentType.php @@ -24,7 +24,8 @@ use Symfony\Component\Serializer\Attribute\Groups; * Timestampable/Blamable (referentiel statique whiteliste dans * EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe * `client:read:accounting` permet l'embarquement dans la reponse Client ; - * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0). + * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ; + * `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis). */ #[ApiResource( operations: [ @@ -51,15 +52,15 @@ class PaymentType #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?int $id = null; #[ORM\Column(length: 30)] - #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $code = null; #[ORM\Column(length: 120)] - #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $label = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Commercial/Domain/Entity/TvaMode.php b/src/Module/Commercial/Domain/Entity/TvaMode.php index e28072f..4cd01c1 100644 --- a/src/Module/Commercial/Domain/Entity/TvaMode.php +++ b/src/Module/Commercial/Domain/Entity/TvaMode.php @@ -25,7 +25,8 @@ use Symfony\Component\Serializer\Attribute\Groups; * EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le * groupe `client:read:accounting` permet d'embarquer le mode dans la reponse * d'un Client (onglet Comptabilite) au lieu d'un IRI ; `supplier:read:accounting` - * fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0). + * fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0) ; + * `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis). */ #[ApiResource( operations: [ @@ -55,15 +56,15 @@ class TvaMode #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?int $id = null; #[ORM\Column(length: 30)] - #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $code = null; #[ORM\Column(length: 120)] - #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $label = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Technique/Infrastructure/DataFixtures/ProviderFixtures.php b/src/Module/Technique/Infrastructure/DataFixtures/ProviderFixtures.php new file mode 100644 index 0000000..998ec15 --- /dev/null +++ b/src/Module/Technique/Infrastructure/DataFixtures/ProviderFixtures.php @@ -0,0 +1,393 @@ += 1 site sur le formulaire principal (RG-3.03), >= 1 + * contact, >= 1 adresse multi-sites (RG-3.05), comptabilite + RIB ; + * - reglement LCR avec RIB (RG-3.08) ; reglement VIREMENT avec banque (RG-3.07) ; + * - 1 prestataire archive (isArchived + archivedAt) pour l'exclusion de la liste + * (RG-3.16) ; + * - prestataires repartis sur des sites DIFFERENTS (86 / 17 / 82) pour exercer le + * cloisonnement par site (RG-3.17) ; + * - mono et multi-categories de type PRESTATAIRE (RG-3.09). + * + * Resolution inter-modules conforme a la regle n°1 (pas d'import de logique) : + * - categories resolues via le contrat Shared CategoryInterface ; + * - sites resolus via le contrat Shared SiteProviderInterface. + * + * Normalisation : valeurs fournies BRUTES, normalisees par ProviderFieldNormalizer + * avant persist, exactement comme le ferait le ProviderProcessor via l'API + * (companyName UPPERCASE, first/last Capitalize, telephones chiffres seuls, emails + * lowercase — RG-3.11). + * + * Idempotence : lookup par companyName normalise (coherent avec l'index unique + * partiel uq_provider_company_name_active). Un prestataire deja present n'est pas + * reconstruit (sous-collections non redupliquees). Rejouable sans doublon. + * + * Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`, la + * fixture ne charge rien : les tests seedent et nettoient leurs propres + * prestataires et comptent sur une table `provider` vierge. Meme garde-fou que + * SupplierFixtures / CategoryFixtures. + */ +class ProviderFixtures extends Fixture implements DependentFixtureInterface +{ + /** + * Type de categorie exige pour un prestataire et ses adresses (RG-3.09). + * Miroir de Provider::REQUIRED_CATEGORY_TYPE_CODE (non importable — regle n°1). + */ + private const string PROVIDER_CATEGORY_TYPE_CODE = 'PRESTATAIRE'; + + /** Cache des categories resolues par nom. */ + private array $categoryCache = []; + + /** Cache des sites resolus par nom. */ + private array $siteCache = []; + + /** ObjectManager courant, capture en debut de load. */ + private ObjectManager $manager; + + public function __construct( + private readonly ProviderFieldNormalizer $normalizer, + private readonly SiteProviderInterface $siteProvider, + #[Autowire('%kernel.environment%')] + private readonly string $environment, + ) {} + + /** + * @return array + */ + public function getDependencies(): array + { + return [ + CategoryFixtures::class, + SitesFixtures::class, + CommercialReferentialFixtures::class, + ]; + } + + public function load(ObjectManager $manager): void + { + // Donnees de demo : dev uniquement. En test, on laisse la table vierge. + if ('test' === $this->environment) { + return; + } + + $this->manager = $manager; + + // === Prestataire COMPLET — VIREMENT + banque (RG-3.07), compta + RIB, === + // === multi-sites sur le formulaire principal ET sur l'adresse. === + [$maintenance, $isNew] = $this->ensureProvider($manager, 'Maintenance Pro SAS', ['Maintenance industrielle'], ['Chatellerault', 'Saint-Jean']); + if ($isNew) { + $maintenance->setSiren('841611054'); + $maintenance->setAccountNumber('P0001'); + $maintenance->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES')); + $maintenance->setNTva('FR12841611054'); + $maintenance->setPaymentDelay($this->paymentDelay($manager, 'J30')); + $maintenance->setPaymentType($this->paymentType($manager, 'VIREMENT')); + $maintenance->setBank($this->bank($manager, 'SG')); + $this->addContact($maintenance, 'Marie', 'Martin', 'Responsable', '05 49 00 00 01', null, 'marie.martin@maintenance-pro.fr'); + $this->addAddress($maintenance, ['Chatellerault', 'Saint-Jean'], '86000', 'Poitiers', '12 rue des Acacias', categoryNames: ['Maintenance industrielle']); + $this->addRib($maintenance, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0); + } + + // === LCR avec RIB (RG-3.08) — site Pommevic === + [$nettoyage, $isNew] = $this->ensureProvider($manager, 'Nettoyage Sud-Ouest', ['Nettoyage'], ['Pommevic']); + if ($isNew) { + $nettoyage->setSiren('775680459'); + $nettoyage->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES')); + $nettoyage->setPaymentDelay($this->paymentDelay($manager, 'J15')); + $nettoyage->setPaymentType($this->paymentType($manager, 'LCR')); + $this->addContact($nettoyage, 'Sophie', 'Marchand', 'Directrice', '05 56 10 20 30', '06 11 22 33 44', 'sophie.marchand@nettoyage-so.fr', 0); + $this->addContact($nettoyage, 'Marc', 'Girard', 'Chef d\'equipe', '05 56 10 20 31', null, 'marc.girard@nettoyage-so.fr', 1); + $this->addAddress($nettoyage, ['Pommevic'], '82400', 'Pommevic', '8 route des Prestations'); + $this->addRib($nettoyage, 'Compte principal', 'BNPAFRPPXXX', 'FR7630006000011234567890189', 0); + } + + // === Multi-categories PRESTATAIRE + reglement CHEQUE (sans banque ni RIB) === + [$transport, $isNew] = $this->ensureProvider($manager, 'Transport Express Atlantique', ['Transport', 'Maintenance industrielle'], ['Saint-Jean']); + if ($isNew) { + $transport->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION')); + $transport->setPaymentType($this->paymentType($manager, 'CHEQUE')); + $this->addContact($transport, 'Thomas', 'Petit', 'Responsable logistique', '05 56 31 32 33', null, 'thomas.petit@transport-express.fr'); + $this->addAddress($transport, ['Saint-Jean'], '17400', 'Fontenet', '4 zone des Transporteurs', categoryNames: ['Transport']); + } + + // === Prestataire minimal — contact par le seul nom (RG-3.04), site 86 === + [$petit, $isNew] = $this->ensureProvider($manager, 'Atelier Soudure Locale', ['Maintenance industrielle'], ['Chatellerault']); + if ($isNew) { + $this->addContact($petit, null, 'Caron', 'Gerant', '05 49 81 82 83', null, 'contact@atelier-soudure.fr'); + $this->addAddress($petit, ['Chatellerault'], '86100', 'Châtellerault', '6 chemin de l\'Atelier'); + } + + // === Prestataire archive (RG-3.16) === + [$ancien, $isNew] = $this->ensureProvider($manager, 'Ancien Prestataire Ferme', ['Nettoyage'], ['Chatellerault'], isArchived: true); + if ($isNew) { + $this->addContact($ancien, null, 'Lambert', 'Ancien contact', '05 49 99 99 99', null, 'contact@ancien-prestataire.fr'); + $this->addAddress($ancien, ['Chatellerault'], '86100', 'Châtellerault', '99 rue Fermée'); + } + + $manager->flush(); + } + + /** + * Cree un prestataire (base normalisee + categories PRESTATAIRE + sites directs) + * s'il n'existe pas, sinon retourne l'existant. Retourne [Provider, isNew] : + * isNew=false bloque la reconstruction des sous-collections (idempotence). + * + * @param list $categoryNames categories de type PRESTATAIRE (RG-3.09) + * @param list $siteNames sites du formulaire principal (RG-3.03, >= 1) + * + * @return array{0: Provider, 1: bool} + */ + private function ensureProvider( + ObjectManager $manager, + string $companyName, + array $categoryNames, + array $siteNames, + bool $isArchived = false, + ): array { + $normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName); + + $existing = $manager->getRepository(Provider::class)->findOneBy(['companyName' => $normalizedName]); + if ($existing instanceof Provider) { + return [$existing, false]; + } + + $provider = new Provider(); + $provider->setCompanyName($normalizedName); + + foreach ($categoryNames as $categoryName) { + $provider->addCategory($this->category($manager, $categoryName)); + } + foreach ($siteNames as $siteName) { + $provider->addSite($this->site($siteName)); + } + + if ($isArchived) { + $provider->setIsArchived(true); + $provider->setArchivedAt(new DateTimeImmutable()); + } + + $manager->persist($provider); + + return [$provider, true]; + } + + /** + * Ajoute un contact normalise au prestataire (cascade persist via + * Provider.contacts). Au moins un champ est rempli (RG-3.04). + */ + private function addContact( + Provider $provider, + ?string $firstName, + ?string $lastName, + ?string $jobTitle, + ?string $phonePrimary, + ?string $phoneSecondary, + ?string $email, + int $position = 0, + ): void { + $contact = new ProviderContact(); + $contact->setProvider($provider); + $contact->setFirstName($this->normalizer->normalizePersonName($firstName)); + $contact->setLastName($this->normalizer->normalizePersonName($lastName)); + $contact->setJobTitle($jobTitle); + $contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary)); + $contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary)); + $contact->setEmail($this->normalizer->normalizeEmail($email)); + $contact->setPosition($position); + + $provider->addContact($contact); + } + + /** + * Ajoute une adresse au prestataire (cascade persist via Provider.addresses). + * Adresse simplifiee M3 : PAS de addressType / bennes / triageProvider. Au + * moins un site est rattache (RG-3.05) ; categories d'adresse de type + * PRESTATAIRE (RG-3.09). + * + * @param list $siteNames au moins un site (RG-3.05) + * @param list $categoryNames categories de type PRESTATAIRE (RG-3.09) + */ + private function addAddress( + Provider $provider, + array $siteNames, + string $postalCode, + string $city, + string $street, + ?string $streetComplement = null, + array $categoryNames = [], + int $position = 0, + ): void { + $address = new ProviderAddress(); + $address->setProvider($provider); + $address->setCountry('France'); + $address->setPostalCode($postalCode); + $address->setCity($city); + $address->setStreet($street); + $address->setStreetComplement($streetComplement); + $address->setPosition($position); + + foreach ($siteNames as $siteName) { + $address->addSite($this->site($siteName)); + } + foreach ($categoryNames as $categoryName) { + $address->addCategory($this->category($this->manager, $categoryName)); + } + + $provider->addAddress($address); + } + + /** + * Ajoute un RIB au prestataire (cascade persist via Provider.ribs). + */ + private function addRib(Provider $provider, string $label, string $bic, string $iban, int $position = 0): void + { + $rib = new ProviderRib(); + $rib->setProvider($provider); + $rib->setLabel($label); + $rib->setBic($bic); + $rib->setIban($iban); + $rib->setPosition($position); + + $provider->addRib($rib); + } + + /** + * Resout une categorie par son nom via le contrat Shared CategoryInterface, + * sans importer le module Catalog (regle n°1). Verifie le type PRESTATAIRE + * (RG-3.09). Mise en cache par nom. + */ + private function category(ObjectManager $manager, string $name): CategoryInterface + { + if (isset($this->categoryCache[$name])) { + return $this->categoryCache[$name]; + } + + $candidates = $manager->getRepository(CategoryInterface::class)->findBy([ + 'name' => $name, + 'deletedAt' => null, + ]); + + foreach ($candidates as $candidate) { + if ($candidate instanceof CategoryInterface + && in_array(self::PROVIDER_CATEGORY_TYPE_CODE, $candidate->getCategoryTypeCodes(), true)) { + return $this->categoryCache[$name] = $candidate; + } + } + + throw new RuntimeException(sprintf( + 'Categorie PRESTATAIRE "%s" introuvable : CategoryFixtures doit tourner avant ProviderFixtures.', + $name, + )); + } + + /** + * Resout un site par son nom via le contrat Shared SiteProviderInterface, sans + * importer le module Sites (regle n°1). Mise en cache par nom. + */ + private function site(string $name): SiteInterface + { + if (isset($this->siteCache[$name])) { + return $this->siteCache[$name]; + } + + $site = $this->siteProvider->findByName($name); + + if (!$site instanceof SiteInterface) { + throw new RuntimeException(sprintf( + 'Site "%s" introuvable : SitesFixtures doit tourner avant ProviderFixtures.', + $name, + )); + } + + return $this->siteCache[$name] = $site; + } + + private function tvaMode(ObjectManager $manager, string $code): TvaMode + { + $mode = $manager->getRepository(TvaMode::class)->findOneBy(['code' => $code]); + + if (!$mode instanceof TvaMode) { + throw new RuntimeException(sprintf( + 'TvaMode "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.', + $code, + )); + } + + return $mode; + } + + private function paymentDelay(ObjectManager $manager, string $code): PaymentDelay + { + $delay = $manager->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]); + + if (!$delay instanceof PaymentDelay) { + throw new RuntimeException(sprintf( + 'PaymentDelay "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.', + $code, + )); + } + + return $delay; + } + + private function paymentType(ObjectManager $manager, string $code): PaymentType + { + $type = $manager->getRepository(PaymentType::class)->findOneBy(['code' => $code]); + + if (!$type instanceof PaymentType) { + throw new RuntimeException(sprintf( + 'PaymentType "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.', + $code, + )); + } + + return $type; + } + + private function bank(ObjectManager $manager, string $code): Bank + { + $bank = $manager->getRepository(Bank::class)->findOneBy(['code' => $code]); + + if (!$bank instanceof Bank) { + throw new RuntimeException(sprintf( + 'Bank "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.', + $code, + )); + } + + return $bank; + } +} diff --git a/tests/Module/Technique/Api/AbstractProviderApiTestCase.php b/tests/Module/Technique/Api/AbstractProviderApiTestCase.php index b4326ba..2992694 100644 --- a/tests/Module/Technique/Api/AbstractProviderApiTestCase.php +++ b/tests/Module/Technique/Api/AbstractProviderApiTestCase.php @@ -8,12 +8,15 @@ 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\PaymentDelay; use App\Module\Commercial\Domain\Entity\PaymentType; +use App\Module\Commercial\Domain\Entity\TvaMode; 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\ProviderAddress; use App\Module\Technique\Domain\Entity\ProviderContact; use App\Module\Technique\Domain\Entity\ProviderRib; use App\Tests\Module\Core\Api\AbstractApiTestCase; @@ -320,6 +323,121 @@ abstract class AbstractProviderApiTestCase extends AbstractApiTestCase return $rib; } + /** + * Seede un prestataire COMPLET (sans passer par l'API — validations applicatives + * non rejouees mais CHECK BDD respectes) : bloc comptable non nul (SIREN + refs), + * >= 1 RIB, >= 2 sites en relation DIRECTE (formulaire principal, RG-3.03), >= 1 + * adresse multi-sites (>= 2 sites) avec >= 1 categorie PRESTATAIRE et >= 1 contact, + * >= 1 contact et >= 1 categorie sur le prestataire. Socle du contrat de + * serialisation et de la DoD (§ 4.0.bis), jumeau de seedCompleteSupplier (M2) + * mais SANS onglet Information (absent au M3) et AVEC sites directs sur le + * prestataire (NOUVEAU M3 — la liste affiche provider.sites, pas un agregat + * d'adresses). + * + * @param string $paymentTypeCode code du type de reglement a poser (defaut LCR, + * coherent avec le RIB seede ; RG-3.08) + */ + protected function seedCompleteProvider(string $companyName, string $paymentTypeCode = 'LCR'): Provider + { + $em = $this->getEm(); + + // Nom unique parmi les actifs (index partiel uq_provider_company_name_active). + $suffix = substr(bin2hex(random_bytes(3)), 0, 6); + + $provider = new Provider(); + $provider->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8')); + $provider->addCategory($this->providerCategory('NETTOYAGE')); + + // Bloc comptable non nul (gating par omission cote sans accounting.view). + $provider->setSiren('987654321'); + $provider->setAccountNumber('P0001'); + $provider->setNTva('FR00987654321'); + $provider->setTvaMode($this->tvaMode('FRANCE_VENTES')); + $provider->setPaymentDelay($this->paymentDelay('J30')); + $provider->setPaymentType($this->paymentType($paymentTypeCode)); + if ('VIREMENT' === $paymentTypeCode) { + $provider->setBank($this->bank('SG')); + } + + // >= 2 sites fixtures : relation DIRECTE provider.sites (RG-3.03) pour la + // LISTE + reutilises sur l'adresse multi-sites pour le DETAIL. + $sites = $em->getRepository(Site::class)->findBy([], null, 2); + self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites fixtures requis (SitesFixtures).'); + foreach ($sites as $site) { + $provider->addSite($site); + } + $em->persist($provider); + + $contact = new ProviderContact(); + $contact->setProvider($provider); + $contact->setFirstName('Marie'); + $contact->setLastName('Martin'); + $contact->setJobTitle('Responsable'); + $contact->setPhonePrimary('0612345678'); + $contact->setEmail('marie.martin@seed.test'); + $provider->addContact($contact); + $em->persist($contact); + + // Adresse simplifiee M3 (PAS de addressType / bennes / triageProvider). + $address = new ProviderAddress(); + $address->setProvider($provider); + $address->setCountry('France'); + $address->setPostalCode('86000'); + $address->setCity('Poitiers'); + $address->setStreet('12 rue des Acacias'); + foreach ($sites as $site) { + $address->addSite($site); + } + $address->addCategory($this->providerCategory('NETTOYAGE')); + $address->addContact($contact); + $provider->addAddress($address); + $em->persist($address); + + $rib = new ProviderRib(); + $rib->setProvider($provider); + $rib->setLabel('Compte principal'); + $rib->setBic(self::VALID_BIC); + $rib->setIban(self::VALID_IBAN); + $provider->addRib($rib); + $em->persist($rib); + + $em->flush(); + + return $provider; + } + + /** + * Recupere un mode de TVA seede (CommercialReferentialFixtures) par code (ex. + * FRANCE_VENTES). Echoue explicitement si absent (fixtures non chargees). + */ + protected function tvaMode(string $code): TvaMode + { + $tvaMode = $this->getEm()->getRepository(TvaMode::class)->findOneBy(['code' => $code]); + + self::assertNotNull( + $tvaMode, + sprintf('Mode de TVA "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code), + ); + + return $tvaMode; + } + + /** + * Recupere un delai de reglement seede (CommercialReferentialFixtures) par code + * (ex. J30). Echoue explicitement si absent (fixtures non chargees). + */ + protected function paymentDelay(string $code): PaymentDelay + { + $paymentDelay = $this->getEm()->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]); + + self::assertNotNull( + $paymentDelay, + sprintf('Delai de reglement "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code), + ); + + return $paymentDelay; + } + /** * Recupere un type de reglement seede (CommercialReferentialFixtures) par code * (ex. LCR, VIREMENT). Echoue explicitement si absent (fixtures non chargees). diff --git a/tests/Module/Technique/Api/ProviderAuditTest.php b/tests/Module/Technique/Api/ProviderAuditTest.php new file mode 100644 index 0000000..60ab5ac --- /dev/null +++ b/tests/Module/Technique/Api/ProviderAuditTest.php @@ -0,0 +1,162 @@ + ligne audit_log entity_type='technique.Provider' + * avec l'action et le diff attendus ; + * - RIB : `#[Auditable]` SANS `#[AuditIgnore]` sur iban/bic -> ces champs sensibles + * DOIVENT apparaitre dans le diff audite (decision § 2.7, miroir M1/M2) ; + * - M2M `sites` : un PATCH du formulaire principal qui modifie les sites trace la + * relation many-to-many (audit M2M automatique, § 2.7). + * + * @internal + */ +final class ProviderAuditTest extends AbstractProviderApiTestCase +{ + private const string PROVIDER_TYPE = 'technique.Provider'; + private const string RIB_TYPE = 'technique.ProviderRib'; + + private ?Connection $auditConnection = null; + + protected function setUp(): void + { + parent::setUp(); + self::bootKernel(); + + /** @var Connection $conn */ + $conn = self::getContainer()->get('doctrine.dbal.audit_connection'); + $this->auditConnection = $conn; + } + + protected function tearDown(): void + { + if (null !== $this->auditConnection) { + $this->auditConnection->close(); + } + parent::tearDown(); + } + + public function testPostProviderIsAudited(): void + { + $admin = $this->createAdminClient(); + $payload = $this->validMainPayload('Audit Created Co', [self::SITE_86]); + + $created = $admin->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $payload, + ])->toArray(); + self::assertResponseStatusCodeSame(201); + + self::assertGreaterThanOrEqual( + 1, + $this->countAudit(self::PROVIDER_TYPE, (string) $created['id'], 'create'), + 'Un audit_log "create" doit etre genere pour le prestataire.', + ); + } + + public function testPatchProviderIsAudited(): void + { + $admin = $this->createAdminClient(); + $seed = $this->seedProvider('Audit Patch Co', [self::SITE_86]); + + $admin->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Audit Patch Renamed'], + ]); + self::assertResponseStatusCodeSame(200); + + self::assertGreaterThanOrEqual( + 1, + $this->countAudit(self::PROVIDER_TYPE, (string) $seed->getId(), 'update'), + 'Un audit_log "update" doit etre genere pour le PATCH.', + ); + } + + public function testArchiveProviderIsAudited(): void + { + $admin = $this->createAdminClient(); + $seed = $this->seedProvider('Audit Archive Co', [self::SITE_86]); + + $admin->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + self::assertResponseStatusCodeSame(200); + + $changes = $this->latestChanges(self::PROVIDER_TYPE, (string) $seed->getId(), 'update'); + self::assertArrayHasKey('isArchived', $changes, 'Le diff d\'archivage doit tracer isArchived.'); + } + + public function testPatchSitesIsAuditedAsManyToMany(): void + { + $admin = $this->createAdminClient(); + $seed = $this->seedProvider('Audit Sites Co', [self::SITE_86]); + + // PATCH du formulaire principal : on passe a 2 sites (86 + 17). L'audit M2M + // automatique (§ 2.7) doit tracer la relation `sites` dans le diff. + $admin->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['sites' => [ + '/api/sites/'.$this->site(self::SITE_86)->getId(), + '/api/sites/'.$this->site(self::SITE_17)->getId(), + ]], + ]); + self::assertResponseStatusCodeSame(200); + + $changes = $this->latestChanges(self::PROVIDER_TYPE, (string) $seed->getId(), 'update'); + self::assertArrayHasKey('sites', $changes, 'La modification M2M des sites doit etre tracee.'); + } + + public function testRibCreateAuditIncludesIbanAndBic(): void + { + $admin = $this->createAdminClient(); + $seed = $this->seedProvider('Rib Audit Host', [self::SITE_86]); + + $rib = $admin->request('POST', '/api/providers/'.$seed->getId().'/ribs', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'label' => 'Compte audite', + 'bic' => self::VALID_BIC, + 'iban' => self::VALID_IBAN, + ], + ])->toArray(); + self::assertResponseStatusCodeSame(201); + + $changes = $this->latestChanges(self::RIB_TYPE, (string) $rib['id'], 'create'); + self::assertArrayHasKey('iban', $changes, 'iban doit figurer dans le diff audite (pas d\'AuditIgnore).'); + self::assertArrayHasKey('bic', $changes, 'bic doit figurer dans le diff audite (pas d\'AuditIgnore).'); + self::assertSame(self::VALID_IBAN, $changes['iban']); + self::assertSame(self::VALID_BIC, $changes['bic']); + } + + /** + * Decode le `changes` (diff) de la derniere ligne audit_log correspondante. + * + * @return array + */ + private function latestChanges(string $type, string $id, string $action): array + { + $rows = $this->auditConnection->fetchAllAssociative( + 'SELECT changes FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action ORDER BY performed_at DESC', + ['type' => $type, 'id' => $id, 'action' => $action], + ); + self::assertGreaterThanOrEqual(1, count($rows), sprintf('Un audit_log "%s" doit exister pour %s#%s.', $action, $type, $id)); + + return json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR); + } + + private function countAudit(string $type, string $id, string $action): int + { + return (int) $this->auditConnection->fetchOne( + 'SELECT COUNT(*) FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action', + ['type' => $type, 'id' => $id, 'action' => $action], + ); + } +} diff --git a/tests/Module/Technique/Api/ProviderListTest.php b/tests/Module/Technique/Api/ProviderListTest.php index 367d253..836cf6e 100644 --- a/tests/Module/Technique/Api/ProviderListTest.php +++ b/tests/Module/Technique/Api/ProviderListTest.php @@ -80,4 +80,91 @@ final class ProviderListTest extends AbstractProviderApiTestCase self::assertSame(1, $body['totalItems']); self::assertSame('SITE 17 ONLY', $body['member'][0]['companyName']); } + + public function testPaginationDisabledReturnsFullCollection(): void + { + $token = $this->token(); + for ($i = 0; $i < 3; ++$i) { + $this->seedProvider($token.' Item'.$i, [self::SITE_86]); + } + + $client = $this->createAdminClient(); + // ?pagination=false : echappatoire pour alimenter un