test(technique) : couvrir RG-3.x PHPUnit + capturer le contrat JSON (ProviderSerializationContractTest, ProviderAuditTest, fixtures démo) (ERP-139)
Ajoute provider:read:accounting sur les réfs comptables partagées (TvaMode/PaymentDelay/PaymentType/Bank) pour embarquer {id,code,label} au lieu d un IRI nu (réplique fix ERP-92). Helper seedCompleteProvider, anti-N+1 + pagination=false + filtre typeCode, restauration conflit 409, fixtures démo idempotentes. Captures JSON réelles collées dans spec § 4.0.bis.
This commit is contained in:
@@ -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`)
|
||||
|
||||
@@ -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])]
|
||||
|
||||
@@ -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])]
|
||||
|
||||
@@ -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])]
|
||||
|
||||
@@ -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])]
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Module\Catalog\Infrastructure\DataFixtures\CategoryFixtures;
|
||||
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\Commercial\Infrastructure\DataFixtures\CommercialReferentialFixtures;
|
||||
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
|
||||
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
||||
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\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SiteProviderInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Fixtures dev/test du module Technique : prestataires de demonstration couvrant
|
||||
* les cas metier RG-3.xx du repertoire prestataires (M3), jumelles des fixtures
|
||||
* fournisseurs (M2). Theme : prestations techniques (maintenance, nettoyage,
|
||||
* transport).
|
||||
*
|
||||
* Cas pivots couverts (§ 8.4) :
|
||||
* - prestataire COMPLET : >= 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<int, class-string>
|
||||
*/
|
||||
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<string> $categoryNames categories de type PRESTATAIRE (RG-3.09)
|
||||
* @param list<string> $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<string> $siteNames au moins un site (RG-3.05)
|
||||
* @param list<string> $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;
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Tests Audit du repertoire prestataires (M3, spec § 6). Jumeau du
|
||||
* {@see \App\Tests\Module\Commercial\Api\SupplierAuditTest} (M2). Couvre :
|
||||
* - POST / PATCH / archivage -> 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<string, mixed>
|
||||
*/
|
||||
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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 <select> (regle n°13).
|
||||
$data = $client->request('GET', '/api/providers?search='.$token.'&pagination=false', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
])->toArray();
|
||||
|
||||
self::assertArrayHasKey('member', $data);
|
||||
self::assertCount(3, $data['member']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anti N+1 (§ 2.12) : le nombre de requetes SQL de la liste ne doit PAS croitre
|
||||
* avec le nombre de prestataires. On mesure pour N=2 puis N=4 (memes relations
|
||||
* embarquees : categories + sites directs + adresses.sites) et on exige un
|
||||
* compte IDENTIQUE — preuve que l'hydratation est batchee (WHERE IN) et non par
|
||||
* ligne.
|
||||
*/
|
||||
public function testListQueryCountDoesNotGrowWithRowCount(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$token = $this->token();
|
||||
|
||||
$this->seedCompleteProvider($token.' A');
|
||||
$this->seedCompleteProvider($token.' B');
|
||||
$countFor2 = $this->countListQueries($token);
|
||||
|
||||
$this->seedCompleteProvider($token.' C');
|
||||
$this->seedCompleteProvider($token.' D');
|
||||
$countFor4 = $this->countListQueries($token);
|
||||
|
||||
self::assertSame(
|
||||
$countFor2,
|
||||
$countFor4,
|
||||
sprintf('Anti N+1 : le nombre de requetes liste doit etre constant (%d pour 2, %d pour 4).', $countFor2, $countFor4),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtre ?typeCode= (cree au M2, reutilise au M3) : GET /api/categories?typeCode=
|
||||
* PRESTATAIRE ne renvoie QUE les categories de type PRESTATAIRE — prerequis des
|
||||
* multi-selects Categorie du prestataire (DoD § 4.7).
|
||||
*/
|
||||
public function testCategoriesTypeCodeFilterReturnsOnlyPrestataire(): void
|
||||
{
|
||||
$prestataire = $this->providerCategory('NETTOYAGE');
|
||||
$foreign = $this->foreignCategory();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$data = $client->request('GET', '/api/categories?typeCode=PRESTATAIRE&pagination=false', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
])->toArray();
|
||||
|
||||
$ids = array_column($data['member'], 'id');
|
||||
self::assertContains($prestataire->getId(), $ids, 'La categorie PRESTATAIRE doit etre presente.');
|
||||
self::assertNotContains($foreign->getId(), $ids, 'Une categorie d\'un autre type doit etre filtree.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte les requetes SQL emises par UN GET liste filtre, via le data holder de
|
||||
* debug Doctrine. Le holder est remis a zero juste avant la requete pour isoler
|
||||
* ses requetes (hors login).
|
||||
*/
|
||||
private function countListQueries(string $token): int
|
||||
{
|
||||
$http = $this->createAdminClient();
|
||||
$holder = self::getContainer()->get('doctrine.debug_data_holder');
|
||||
$holder->reset();
|
||||
|
||||
$http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]]);
|
||||
|
||||
$data = $holder->getData();
|
||||
|
||||
return count($data['default'] ?? []);
|
||||
}
|
||||
|
||||
private function token(): string
|
||||
{
|
||||
return 'List'.substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,4 +156,21 @@ final class ProviderRbacGatingTest extends AbstractProviderApiTestCase
|
||||
self::assertTrue($reloaded->isArchived());
|
||||
self::assertNotNull($reloaded->getArchivedAt());
|
||||
}
|
||||
|
||||
public function testRestoreWithNameConflictReturns409(): void
|
||||
{
|
||||
// Un prestataire archive porte un nom qu'un prestataire ACTIF a repris
|
||||
// entre-temps (autorise par l'index partiel : l'archive n'y figure pas).
|
||||
$archived = $this->seedProvider('Conflit Co', [self::SITE_86], isArchived: true);
|
||||
$this->seedProvider('Conflit Co', [self::SITE_86]); // actif, meme nom normalise
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('PATCH', '/api/providers/'.$archived->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => false],
|
||||
]);
|
||||
|
||||
// RG-3.14 : restaurer ferait deux actifs homonymes -> 409 (pas de 500 SQL).
|
||||
self::assertSame(409, $response->getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
/**
|
||||
* Tests anti-regression du CONTRAT DE SERIALISATION du repertoire prestataires
|
||||
* (M3, spec-back § 4.0 / § 4.0.bis). Jumeau du
|
||||
* {@see \App\Tests\Module\Commercial\Api\SupplierSerializationContractTest} (M2),
|
||||
* il reverifie sur le JSON REEL les pieges silencieux herites du M1/M2 :
|
||||
* - #4 : fuite RIB (IBAN/BIC) vers un user sans accounting.view -> cle `ribs`
|
||||
* ABSENTE pour un profil type Commerciale (gating par omission).
|
||||
* - #3 : booleens droppes (Groups sur la propriete `isX`, getter derivant `x`)
|
||||
* -> isArchived present dans le detail.
|
||||
* - #1 : categories embarquees sans code/name -> code + name presents en LISTE
|
||||
* ET DETAIL (provider ET adresse).
|
||||
* - #2 : sites embarques en IRI nu -> name + postalCode presents en LISTE
|
||||
* (relation DIRECTE provider.sites — RG-3.03) ET DETAIL (addresses[].sites[]).
|
||||
* - ERP-92 : refs comptables (tvaMode/paymentDelay/paymentType/bank) embarquees
|
||||
* {id, code, label} et non IRI nu (le groupe provider:read:accounting doit
|
||||
* etre porte par les entites partagees — fix ERP-139, sinon IRI nu).
|
||||
* Plus l'enveloppe AP4 (member/totalItems/view sans prefixe hydra:, archives exclus).
|
||||
*
|
||||
* REGLE D'OR : ces tests assertent sur le CORPS JSON reel, jamais sur les
|
||||
* annotations. Toute regression de groupe de serialisation casse ici.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderSerializationContractTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
// === #4 — Gating des RIB par accounting.view ===
|
||||
|
||||
public function testRibsPresentForAdminWithAccountingView(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Rib Admin Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Admin bypass RBAC -> accounting.view -> RIB embarques (label/bic/iban).
|
||||
self::assertArrayHasKey('ribs', $data);
|
||||
self::assertNotEmpty($data['ribs']);
|
||||
self::assertSame('Compte principal', $data['ribs'][0]['label']);
|
||||
self::assertSame(self::VALID_IBAN, $data['ribs'][0]['iban']);
|
||||
self::assertSame(self::VALID_BIC, $data['ribs'][0]['bic']);
|
||||
}
|
||||
|
||||
public function testRibsAbsentForUserWithoutAccountingView(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Rib Commerciale Co');
|
||||
|
||||
// Profil type Commerciale : technique.providers.view SANS accounting.view.
|
||||
// createUserWithPermissions n'attache pas de currentSite -> pas de
|
||||
// cloisonnement, on isole le gating comptable du comportement site.
|
||||
$creds = $this->createUserWithPermissions(['technique.providers.view']);
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// La cle `ribs` est ABSENTE (pas null) : le groupe provider:read:accounting
|
||||
// n'est pas ajoute au contexte -> getRibs() jamais serialise. Fin de la
|
||||
// fuite IBAN/BIC (piege n°4 du M1).
|
||||
self::assertArrayNotHasKey('ribs', $data);
|
||||
}
|
||||
|
||||
// === #4.bis — Gating par OMISSION des scalaires comptables ===
|
||||
|
||||
public function testAccountingScalarsGatedByOmission(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Compta Gating Co');
|
||||
$id = $provider->getId();
|
||||
|
||||
// Admin : scalaires comptables presents.
|
||||
$admin = $this->createAdminClient();
|
||||
$adminData = $admin->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertArrayHasKey('siren', $adminData);
|
||||
self::assertSame('987654321', $adminData['siren']);
|
||||
self::assertArrayHasKey('accountNumber', $adminData);
|
||||
self::assertArrayHasKey('paymentType', $adminData);
|
||||
|
||||
// Sans accounting.view : scalaires comptables ABSENTS (omission, pas null).
|
||||
$creds = $this->createUserWithPermissions(['technique.providers.view']);
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
$data = $http->request('GET', '/api/providers/'.$id, ['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);
|
||||
}
|
||||
|
||||
// === ERP-139 — Refs comptables embarquees {id, code, label} et non IRI nu ===
|
||||
|
||||
public function testAccountingReferentialsEmbedIdCodeLabel(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
// Reglement VIREMENT -> banque renseignee : on couvre les 4 referentiels.
|
||||
$provider = $this->seedCompleteProvider('Refs Embed Co', 'VIREMENT');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Avant fix ERP-139 : ces refs sortaient en IRI nu ("/api/tva_modes/30")
|
||||
// car les entites partagees ne portaient que client:/supplier:read:accounting,
|
||||
// pas provider:read:accounting. Apres fix : objet {id, code, label} embarque
|
||||
// (le front consultation/edition affiche le libelle sans fetch — § 4.0.bis).
|
||||
foreach (['tvaMode', 'paymentDelay', 'paymentType', 'bank'] as $ref) {
|
||||
self::assertArrayHasKey($ref, $data, sprintf('Le ref comptable "%s" doit etre present.', $ref));
|
||||
self::assertIsArray($data[$ref], sprintf('Le ref "%s" doit etre un objet embarque, pas un IRI nu.', $ref));
|
||||
self::assertArrayHasKey('id', $data[$ref]);
|
||||
self::assertArrayHasKey('label', $data[$ref]);
|
||||
self::assertNotSame('', (string) $data[$ref]['label']);
|
||||
}
|
||||
|
||||
// paymentType embarque aussi son code (logique front VIREMENT/LCR).
|
||||
self::assertArrayHasKey('code', $data['paymentType']);
|
||||
self::assertSame('VIREMENT', $data['paymentType']['code']);
|
||||
}
|
||||
|
||||
// === #3 — Booleen isArchived present dans le JSON ===
|
||||
|
||||
public function testProviderIsArchivedBooleanIsPresentInDetail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Bool Archived Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// isArchived expose via Groups + SerializedName('isArchived') sur le getter :
|
||||
// sans cela Symfony exposerait la cle "archived" et la droppait (piege n°3 M1).
|
||||
self::assertArrayHasKey('isArchived', $data);
|
||||
self::assertFalse($data['isArchived']);
|
||||
}
|
||||
|
||||
// === #1 — Embed code/name des Category (liste ET detail) ===
|
||||
|
||||
public function testCategoriesEmbedCodeAndNameInDetail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Embed Cat Detail Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertNotEmpty($data['categories']);
|
||||
$category = $data['categories'][0];
|
||||
// Avant correctif : seuls @id/@type (category:read absent du contexte).
|
||||
// Apres : code + name embarques.
|
||||
self::assertArrayHasKey('code', $category);
|
||||
self::assertArrayHasKey('name', $category);
|
||||
self::assertSame('NETTOYAGE', $category['code']);
|
||||
|
||||
// Categories d'adresse aussi (category:read dans le contexte du detail).
|
||||
self::assertArrayHasKey('categories', $data['addresses'][0]);
|
||||
self::assertNotEmpty($data['addresses'][0]['categories']);
|
||||
self::assertArrayHasKey('code', $data['addresses'][0]['categories'][0]);
|
||||
}
|
||||
|
||||
public function testCategoriesEmbedCodeAndNameInList(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$token = 'CatList'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
$provider = $this->seedCompleteProvider($token);
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$list = $http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
$row = $this->memberById($list, (int) $provider->getId());
|
||||
self::assertNotNull($row, 'Le prestataire seede doit apparaitre dans la liste filtree.');
|
||||
self::assertNotEmpty($row['categories']);
|
||||
self::assertArrayHasKey('code', $row['categories'][0]);
|
||||
self::assertArrayHasKey('name', $row['categories'][0]);
|
||||
self::assertSame('NETTOYAGE', $row['categories'][0]['code']);
|
||||
}
|
||||
|
||||
// === #2 — Embed name/postalCode des Site (liste via relation directe + detail) ===
|
||||
|
||||
public function testSitesEmbedNameAndPostalCodeInList(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$token = 'SiteList'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
$provider = $this->seedCompleteProvider($token);
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$list = $http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
$row = $this->memberById($list, (int) $provider->getId());
|
||||
self::assertNotNull($row);
|
||||
// sites en relation DIRECTE provider.sites (RG-3.03) : objet Site entier
|
||||
// (name + postalCode), pas un IRI nu (piege n°2 M1). Multi-sites (>= 2).
|
||||
self::assertArrayHasKey('sites', $row);
|
||||
self::assertGreaterThanOrEqual(2, count($row['sites']));
|
||||
self::assertArrayHasKey('name', $row['sites'][0]);
|
||||
self::assertArrayHasKey('postalCode', $row['sites'][0]);
|
||||
self::assertNotSame('', (string) $row['sites'][0]['name']);
|
||||
}
|
||||
|
||||
public function testSitesEmbedNameAndPostalCodeInDetail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Site Detail Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Sites du formulaire principal (relation directe).
|
||||
self::assertArrayHasKey('sites', $data);
|
||||
self::assertGreaterThanOrEqual(2, count($data['sites']));
|
||||
self::assertArrayHasKey('name', $data['sites'][0]);
|
||||
self::assertArrayHasKey('postalCode', $data['sites'][0]);
|
||||
|
||||
// Sites de l'adresse (addresses[].sites[]).
|
||||
$address = $data['addresses'][0];
|
||||
self::assertArrayHasKey('sites', $address);
|
||||
self::assertGreaterThanOrEqual(2, count($address['sites']), 'L\'adresse seedee est multi-sites.');
|
||||
self::assertArrayHasKey('name', $address['sites'][0]);
|
||||
self::assertArrayHasKey('postalCode', $address['sites'][0]);
|
||||
self::assertNotSame('', (string) $address['sites'][0]['name']);
|
||||
}
|
||||
|
||||
// === Detail : sous-collections embarquees ===
|
||||
|
||||
public function testDetailEmbedsContactsAddressesRibs(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Embed Subres Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertNotEmpty($data['contacts']);
|
||||
self::assertSame('Marie', $data['contacts'][0]['firstName']);
|
||||
self::assertSame('Martin', $data['contacts'][0]['lastName']);
|
||||
self::assertArrayHasKey('email', $data['contacts'][0]);
|
||||
|
||||
self::assertNotEmpty($data['addresses']);
|
||||
// M3 : adresse simplifiee, PAS de addressType.
|
||||
self::assertArrayNotHasKey('addressType', $data['addresses'][0]);
|
||||
self::assertSame('Poitiers', $data['addresses'][0]['city']);
|
||||
|
||||
self::assertNotEmpty($data['ribs']);
|
||||
}
|
||||
|
||||
// === refonte-contact V0.2 : pas de contact inline sur le prestataire ===
|
||||
|
||||
public function testProviderHasNoInlineContactFields(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('No Inline Contact Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Les champs de contact vivent UNIQUEMENT sous contacts[] (refonte-contact).
|
||||
foreach (['firstName', 'lastName', 'phonePrimary', 'phoneSecondary', 'email'] as $key) {
|
||||
self::assertArrayNotHasKey($key, $data, sprintf('Le champ inline "%s" ne doit plus exister au niveau du prestataire.', $key));
|
||||
}
|
||||
}
|
||||
|
||||
// === Enveloppe AP4 (sans prefixe hydra:) + exclusion des archives ===
|
||||
|
||||
public function testCollectionEnvelopeShapeAndArchivedExcluded(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$token = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
|
||||
$this->seedProvider($token.' Active', [self::SITE_86]);
|
||||
$this->seedProvider($token.' Archived', [self::SITE_86], isArchived: true);
|
||||
|
||||
// Liste par defaut filtree sur le token : enveloppe member/totalItems sans
|
||||
// prefixe hydra:, archive EXCLU du totalItems (RG-3.16).
|
||||
$default = $http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayHasKey('member', $default);
|
||||
self::assertArrayHasKey('totalItems', $default);
|
||||
self::assertArrayNotHasKey('hydra:member', $default);
|
||||
self::assertArrayNotHasKey('hydra:totalItems', $default);
|
||||
self::assertSame(1, $default['totalItems'], 'Archive exclu du totalItems par defaut.');
|
||||
|
||||
// includeArchived : l'archive reintegre le total.
|
||||
$all = $http->request('GET', '/api/providers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertSame(2, $all['totalItems']);
|
||||
|
||||
// `view` (PartialCollectionView) sans prefixe hydra:.
|
||||
$paged = $http->request('GET', '/api/providers?search='.$token.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertArrayHasKey('view', $paged);
|
||||
self::assertArrayNotHasKey('hydra:view', $paged);
|
||||
}
|
||||
|
||||
/**
|
||||
* DoD (§ 4.0.bis) : capture des reponses JSON REELLES (liste + detail admin +
|
||||
* detail sans accounting.view) pour les coller dans la spec avant de lancer les
|
||||
* tickets front. Le test asserte la forme ; si la variable d'env
|
||||
* PROVIDER_DOD_DUMP est positionnee, il ecrit aussi les 3 corps formates sous
|
||||
* /tmp pour copie.
|
||||
*/
|
||||
public function testDodReferenceJsonShape(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$token = 'DoD'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
$provider = $this->seedCompleteProvider($token);
|
||||
$id = (int) $provider->getId();
|
||||
|
||||
$admin = $this->createAdminClient();
|
||||
$list = $admin->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
$detailAdmin = $admin->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
$creds = $this->createUserWithPermissions(['technique.providers.view']);
|
||||
$restricted = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
$detailRestricted = $restricted->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Forme minimale attendue (la DoD valide que tout champ front est present).
|
||||
self::assertArrayHasKey('member', $list);
|
||||
self::assertArrayHasKey('siren', $detailAdmin);
|
||||
self::assertArrayHasKey('ribs', $detailAdmin);
|
||||
self::assertArrayNotHasKey('siren', $detailRestricted);
|
||||
self::assertArrayNotHasKey('ribs', $detailRestricted);
|
||||
|
||||
if (false !== getenv('PROVIDER_DOD_DUMP')) {
|
||||
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
|
||||
file_put_contents('/tmp/provider-dod-list.json', json_encode($list, $flags));
|
||||
file_put_contents('/tmp/provider-dod-detail-admin.json', json_encode($detailAdmin, $flags));
|
||||
file_put_contents('/tmp/provider-dod-detail-restricted.json', json_encode($detailRestricted, $flags));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrouve un membre de la collection par son id (liste filtree).
|
||||
*
|
||||
* @param array<string, mixed> $collection
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function memberById(array $collection, int $id): ?array
|
||||
{
|
||||
foreach ($collection['member'] ?? [] as $member) {
|
||||
if (($member['id'] ?? null) === $id) {
|
||||
return $member;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user