Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2689b85ebe | |||
| f4bbc79550 | |||
| f057866e75 | |||
| 19fdb50cec | |||
| 368bb50ffb | |||
| 6a83adc00a | |||
| c76c447aa2 | |||
| 19ac8833eb | |||
| c25c33116d | |||
| 17aa61d014 | |||
| 3d4ae391fe | |||
| 04c794addb | |||
| c1e45cd582 | |||
| a6f01400ba | |||
| d0e9f48983 | |||
| c1206fa29c | |||
| 090ea5eb49 | |||
| ee1f344764 | |||
| 3fe0f676f6 | |||
| d5462bcf42 | |||
| 54d8327fa5 | |||
| 09a4b9d464 | |||
| d97b9ce6d0 | |||
| b36520d3b1 | |||
| a340d8139a |
@@ -79,6 +79,7 @@ Regles :
|
||||
- **Toujours `{ toast: false }`** sur l'appel API qui veut un mapping inline (sinon le toast natif d'`useApi` masque le fin).
|
||||
- **Cas metier specifique** (ex: 409 doublon) : `setError('champ', message)` + toast explicite **avant** de deleguer le reste a `handleApiError`. Cf. `useCategoryForm` (doublon RG-1.07).
|
||||
- **Collections** (listes de sous-entites sauvees par un appel par ligne) : une erreur PAR LIGNE via un tableau `ref<Record<string, string>[]>` aligne sur l'index, peuple par `mapViolationsToRecord(error.response._data)` (util pur de `shared/utils/api.ts`). Le composant de ligne expose une prop `:errors` (`Record<string, string>`) bindee sur le `:error` de chaque champ. Cf. `ClientContactBlock` / `ClientAddressBlock` et les submits de `clients/new.vue` / `clients/[id]/edit.vue`.
|
||||
- **Message back technique → surcharge i18n par code** : la plupart des contraintes back portent un message FR explicite (affiche tel quel). Mais une 422 peut porter un message TECHNIQUE non montrable (ex. erreur de type API Platform sur une date non parsable : « Cette valeur doit être de type DateTimeImmutable|null. », voire en anglais selon la negociation). On le surcharge **cote front** via le `code` de violation (UUID Symfony fige, robuste — pas un match sur le texte) : table `VIOLATION_MESSAGE_I18N` + `resolveViolationMessage` dans `shared/utils/api.ts`, appliquee par `useFormErrors`. Ajouter un cas = une entree `code -> cle i18n`. Cas reference : date invalide (MalioDate forwarde la saisie brute via `@update:rawValue`, le back renvoie 422 sur `foundedAt` grace a `collectDenormalizationErrors`, le front affiche `errors.validation.invalidDate`).
|
||||
|
||||
**Interdit** : se contenter d'un toast global sur une 422 quand le back identifie les champs fautifs (`propertyPath`). Reimplementer un mapping `if/else` par champ a la main au lieu d'`useFormErrors` / `mapViolationsToRecord`.
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Module\Commercial\CommercialModule;
|
||||
use App\Module\Core\CoreModule;
|
||||
use App\Module\Sites\SitesModule;
|
||||
use App\Module\Technique\TechniqueModule;
|
||||
use App\Module\Transport\TransportModule;
|
||||
|
||||
return [
|
||||
CoreModule::class,
|
||||
@@ -13,4 +14,5 @@ return [
|
||||
SitesModule::class,
|
||||
CatalogModule::class,
|
||||
TechniqueModule::class,
|
||||
TransportModule::class,
|
||||
];
|
||||
|
||||
@@ -8,16 +8,22 @@ doctrine:
|
||||
default:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
# Exclut `audit_log` de toute operation de comparaison de schema
|
||||
# (doctrine:schema:update, schema:validate, diff de migrations...).
|
||||
# Cette table n'a volontairement aucune entite mappee : elle est
|
||||
# append-only via DBAL brut (AuditLogWriter) pour eviter la
|
||||
# recursion du listener Doctrine. Sans ce filtre, schema:update
|
||||
# la considere comme "orpheline" et genere un `DROP TABLE
|
||||
# audit_log` qui casse la base de test apres chaque
|
||||
# `make test-db-setup`. La creation / suppression de la table
|
||||
# reste pilotee par les migrations (cf. Version20260420202749).
|
||||
schema_filter: '~^(?!audit_log$).+~'
|
||||
# Exclut certaines tables de toute operation de comparaison de
|
||||
# schema (doctrine:schema:update, schema:validate, diff de
|
||||
# migrations...). Ces tables n'ont volontairement aucune entite
|
||||
# mappee :
|
||||
# - `audit_log` : append-only via DBAL brut (AuditLogWriter) pour
|
||||
# eviter la recursion du listener Doctrine.
|
||||
# - `qualimat_carrier` / `qualimat_sync_log` : referentiel
|
||||
# transporteurs synchronise en DBAL brut (upsert `ON CONFLICT`)
|
||||
# par `app:qualimat:sync`, hors ORM.
|
||||
# Sans ce filtre, schema:update les considere comme "orphelines" et
|
||||
# genere un `DROP TABLE` qui casse la base de test apres chaque
|
||||
# `make test-db-setup` (la migration les a creees, schema:update les
|
||||
# supprime juste apres). Creation / suppression restent pilotees par
|
||||
# les migrations (audit_log : Version20260420202749 ; qualimat :
|
||||
# Version20260612150000).
|
||||
schema_filter: '~^(?!(?:audit_log|qualimat_carrier|qualimat_sync_log)$).+~'
|
||||
audit:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
orm:
|
||||
|
||||
@@ -2,4 +2,5 @@ doctrine_migrations:
|
||||
migrations_paths:
|
||||
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||
'App\Module\Core\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Core/Infrastructure/Doctrine/Migrations'
|
||||
'App\Module\Transport\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Transport/Infrastructure/Doctrine/Migrations'
|
||||
enable_profiler: false
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Active le composant HTTP Client (symfony/http-client) et enregistre
|
||||
# l'autowiring de HttpClientInterface. Utilise par les commandes de
|
||||
# synchronisation de referentiels externes (QUALIMAT, IDTF...).
|
||||
framework:
|
||||
http_client:
|
||||
default_options:
|
||||
timeout: 30
|
||||
headers:
|
||||
User-Agent: 'Starseed-ERP (referentiel-sync)'
|
||||
@@ -61,6 +61,23 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Technique" (M3, ERP-138) : pole distinct du Commercial, porte le
|
||||
// repertoire prestataires. L'item est gate par `technique.providers.view` ;
|
||||
// la section disparait automatiquement (SidebarProvider) si le module
|
||||
// `technique` est desactive ou si l'user n'a pas la permission.
|
||||
[
|
||||
'label' => 'sidebar.technique.section',
|
||||
'icon' => 'mdi:account-convert-outline',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.technique.providers',
|
||||
'to' => '/providers',
|
||||
'icon' => 'mdi:account-wrench-outline',
|
||||
'module' => 'technique',
|
||||
'permission' => 'technique.providers.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Administration" : regroupe toutes les pages de configuration
|
||||
// applicative (RBAC, users, sites, audit log).
|
||||
//
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.109'
|
||||
app.version: '0.1.122'
|
||||
|
||||
@@ -176,7 +176,7 @@ Anti-N+1 (le code fera foi) : le `DoctrineProviderRepository` ne fetch-joine PAS
|
||||
- **Filtre LISTE** (`ProviderProvider` ou query extension dédiée `ProviderSiteScopeExtension`) : si l'user **n'a pas** `sites.bypass_scope` ET que `CurrentSiteProvider::get()` retourne un site → ne renvoyer que les prestataires dont `provider.sites` **contient** le `currentSite` (jointure `provider_site` + `WHERE site = :currentSite`). Si l'user a `bypass_scope` (Admin, profils consolidation) → aucun filtre (tous sites). Si `currentSite = null` (mode dégradé / module Sites off) → aligné `site-aware.md § 5` (no-op lecture, à documenter).
|
||||
- **Filtre DÉTAIL** (`Get`) : un user sans `bypass_scope` qui demande un prestataire **hors de son site courant** → **404** (cohérence : ne pas révéler l'existence d'une ligne hors périmètre).
|
||||
- **Écriture (décision Matthieu, 11/06)** : un user **sans** `bypass_scope` ne peut attacher **que les sites dont il dispose** (ses `user_site`) — sur le formulaire principal (`provider.sites`, RG-3.03) **comme** sur chaque adresse (`provider_address.sites`, RG-3.05). Tout site hors de ses `user_site` dans le payload → **422** sur `sites`. Un user `bypass_scope` (Admin) peut attacher n'importe quel site. Garde porté par le `ProviderProcessor` (POST + PATCH + sous-ressource adresses).
|
||||
- **Cohérence sous-ressources** (`/providers/{id}/...`) : le détail étant déjà gardé en 404 hors périmètre, les sous-ressources héritent du garde-fou parent (cf. `site-aware.md § 6.1`).
|
||||
- **Cohérence sous-ressources** (Contacts / Adresses / RIB) : le cloisonnement du parent **n'est PAS hérité automatiquement** — les opérations `Get` / `Patch` / `Delete` des sous-ressources passent par le provider Doctrine par défaut (et `SiteScopedQueryExtension` ne filtre que les `SiteAwareInterface`, ce que ces entités ne sont pas). Le garde-fou est donc posé **explicitement** : (a) en lecture/édition/suppression via le provider décoré `ProviderSubResourceItemProvider` (un parent hors périmètre → **404**) ; (b) en création (`POST /providers/{id}/...`) via `ProviderSiteScopeChecker::assertInScope` dans chaque processor (parent hors périmètre → **404**). La décision de scope est centralisée dans `ProviderSiteScopeChecker` (source unique partagée avec `ProviderProvider`). ⚠️ Ne pas retirer ces gardes en les croyant redondants : sans eux, un user cloisonné peut lire l'IBAN/BIC d'un RIB d'un autre site.
|
||||
|
||||
> **Conséquence RBAC** : la colonne « Consultation » du docx (« Tout » vs « son site uniquement ») se réalise **par `sites.bypass_scope`**, pas par le code de rôle. Décision d'attribution par défaut (à acter au ticket RBAC) : `bypass_scope` aux profils Admin (auto) + Bureau + Compta + Commerciale (ils voient « Tout » d'après le docx) ; **Usine ne l'a pas** → cloisonné à son site. Si MALIO préfère que Bureau/Commerciale soient aussi cloisonnés, il suffit de ne pas leur donner `bypass_scope` — **aucun code à changer** (c'est l'intérêt de piloter par user/permission et non par rôle).
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
"clients": "Répertoire clients",
|
||||
"suppliers": "Répertoire fournisseurs"
|
||||
},
|
||||
"technique": {
|
||||
"section": "Technique",
|
||||
"providers": "Répertoire prestataires"
|
||||
},
|
||||
"core": {
|
||||
"roles": "Gestion des rôles",
|
||||
"users": "Utilisateurs",
|
||||
@@ -362,6 +366,130 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"technique": {
|
||||
"providers": {
|
||||
"title": "Répertoire prestataires",
|
||||
"add": "Ajouter",
|
||||
"export": "Exporter",
|
||||
"empty": "Aucun prestataire pour l'instant.",
|
||||
"column": {
|
||||
"companyName": "Nom",
|
||||
"categories": "Catégories",
|
||||
"sites": "Site",
|
||||
"lastActivity": "Dernière activité"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"search": "Recherche",
|
||||
"categories": "Catégories",
|
||||
"sites": "Sites",
|
||||
"status": "Statut",
|
||||
"includeArchived": "Inclure les archivés",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
"tab": {
|
||||
"contact": "Contact",
|
||||
"contacts": "Contacts",
|
||||
"address": "Adresse",
|
||||
"reports": "Rapports",
|
||||
"exchanges": "Échanges",
|
||||
"accounting": "Comptabilité"
|
||||
},
|
||||
"action": {
|
||||
"edit": "Modifier",
|
||||
"archive": "Archiver",
|
||||
"restore": "Restaurer"
|
||||
},
|
||||
"consultation": {
|
||||
"title": "Fiche prestataire",
|
||||
"back": "Retour au répertoire",
|
||||
"loading": "Chargement…",
|
||||
"notFound": "Prestataire introuvable.",
|
||||
"confirmArchive": "Archiver ce prestataire ? Il n'apparaîtra plus dans le répertoire actif.",
|
||||
"confirmRestore": "Restaurer ce prestataire ? Il réapparaîtra dans le répertoire actif."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le prestataire",
|
||||
"back": "Retour à la fiche",
|
||||
"loading": "Chargement…",
|
||||
"notFound": "Prestataire introuvable.",
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"form": {
|
||||
"title": "Ajouter un prestataire",
|
||||
"back": "Précédent",
|
||||
"submit": "Valider",
|
||||
"duplicateCompany": "Un prestataire portant ce nom de société existe déjà.",
|
||||
"main": {
|
||||
"companyName": "Nom du prestataire (Entreprise)",
|
||||
"categories": "Catégorie",
|
||||
"sites": "Site"
|
||||
},
|
||||
"errors": {
|
||||
"nameRequired": "Le nom du prestataire est obligatoire.",
|
||||
"siteRequired": "Sélectionnez au moins un site.",
|
||||
"categoryRequired": "Sélectionnez au moins une catégorie."
|
||||
},
|
||||
"contact": {
|
||||
"lastName": "Nom",
|
||||
"firstName": "Prénom",
|
||||
"jobTitle": "Fonction",
|
||||
"email": "Email",
|
||||
"phonePrimary": "Téléphone",
|
||||
"phoneSecondary": "Téléphone (2)",
|
||||
"addPhone": "Ajouter un numéro",
|
||||
"remove": "Supprimer le contact",
|
||||
"add": "Nouveau contact"
|
||||
},
|
||||
"address": {
|
||||
"sites": "Sites",
|
||||
"categories": "Catégorie",
|
||||
"contacts": "Contact(s) rattaché(s)",
|
||||
"country": "Pays",
|
||||
"postalCode": "Code postal",
|
||||
"city": "Ville",
|
||||
"street": "Adresse",
|
||||
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
|
||||
"streetComplement": "Adresse complémentaire",
|
||||
"remove": "Supprimer l'adresse",
|
||||
"add": "Nouvelle adresse",
|
||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||
},
|
||||
"accounting": {
|
||||
"siren": "SIREN",
|
||||
"accountNumber": "Numéro de compte",
|
||||
"tvaMode": "Mode de TVA",
|
||||
"nTva": "N° de TVA",
|
||||
"paymentDelay": "Délai de règlement",
|
||||
"paymentType": "Type de règlement",
|
||||
"bank": "Banque",
|
||||
"ribLabel": "Libellé",
|
||||
"ribBic": "BIC",
|
||||
"ribIban": "IBAN",
|
||||
"addRib": "Ajouter un RIB",
|
||||
"removeRib": "Supprimer le RIB"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Confirmer la suppression",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Supprimer",
|
||||
"contact": "Supprimer ce contact ?",
|
||||
"address": "Supprimer cette adresse ?",
|
||||
"rib": "Supprimer ce RIB ?"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"error": "Une erreur est survenue. Réessayez.",
|
||||
"exportError": "L'export du répertoire prestataires a échoué. Réessayez.",
|
||||
"createSuccess": "Prestataire créé avec succès",
|
||||
"updateSuccess": "Prestataire mis à jour avec succès",
|
||||
"addComplete": "Prestataire ajouté",
|
||||
"archiveSuccess": "Prestataire archivé avec succès",
|
||||
"restoreSuccess": "Prestataire restauré avec succès"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"login": "Connexion",
|
||||
"logout": "Deconnexion",
|
||||
@@ -386,7 +514,10 @@
|
||||
},
|
||||
"title": "Erreur",
|
||||
"generic": "Une erreur est survenue.",
|
||||
"unknown": "Erreur inconnue."
|
||||
"unknown": "Erreur inconnue.",
|
||||
"validation": {
|
||||
"invalidDate": "Date invalide"
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"selector": {
|
||||
|
||||
@@ -187,7 +187,7 @@ import {
|
||||
addressTypeFromFlags,
|
||||
isBillingEmailRequired,
|
||||
type AddressType,
|
||||
} from '~/modules/commercial/utils/clientFormRules'
|
||||
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation'
|
||||
import type { ClientDetail } from '~/modules/commercial/utils/forms/clientConsultation'
|
||||
|
||||
/**
|
||||
* Chargement et actions d'archivage d'un client unique (ecran « Consultation
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
|
||||
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||
|
||||
/**
|
||||
* Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
:readonly="businessReadonly"
|
||||
:editable="true"
|
||||
:error="informationErrors.errors.foundedAt"
|
||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.employeesCount"
|
||||
@@ -401,7 +402,7 @@ import {
|
||||
mapAddressToDraft,
|
||||
mapRibToDraft,
|
||||
type ClientDetail,
|
||||
} from '~/modules/commercial/utils/clientConsultation'
|
||||
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||
import {
|
||||
buildAccountingPayload,
|
||||
buildAddressPayload,
|
||||
@@ -417,7 +418,7 @@ import {
|
||||
type ClientEditAbilities,
|
||||
type InformationFormDraft,
|
||||
type MainFormDraft,
|
||||
} from '~/modules/commercial/utils/clientEdit'
|
||||
} from '~/modules/commercial/utils/forms/clientEdit'
|
||||
import {
|
||||
buildClientFormTabKeys,
|
||||
isAddressValid,
|
||||
@@ -429,7 +430,7 @@ import {
|
||||
isRibComplete,
|
||||
isRibRequiredForPaymentType,
|
||||
showsRelationAndTriageFields,
|
||||
} from '~/modules/commercial/utils/clientFormRules'
|
||||
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import {
|
||||
emptyAddress,
|
||||
emptyContact,
|
||||
|
||||
@@ -280,7 +280,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useClient } from '~/modules/commercial/composables/useClient'
|
||||
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/clientFormRules'
|
||||
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
import {
|
||||
canEditClient,
|
||||
@@ -297,7 +297,7 @@ import {
|
||||
showRestoreAction,
|
||||
type ClientDetail,
|
||||
type SelectOption,
|
||||
} from '~/modules/commercial/utils/clientConsultation'
|
||||
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
|
||||
|
||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
:readonly="isValidated('information')"
|
||||
:editable="true"
|
||||
:error="informationErrors.errors.foundedAt"
|
||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.employeesCount"
|
||||
@@ -401,12 +402,12 @@ import {
|
||||
isRibRequiredForPaymentType,
|
||||
lastFillableTabKey,
|
||||
showsRelationAndTriageFields,
|
||||
} from '~/modules/commercial/utils/clientFormRules'
|
||||
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import {
|
||||
buildAddressPayload,
|
||||
buildMainPayload,
|
||||
buildRibPayload,
|
||||
} from '~/modules/commercial/utils/clientEdit'
|
||||
} from '~/modules/commercial/utils/forms/clientEdit'
|
||||
import {
|
||||
emptyAddress,
|
||||
emptyContact,
|
||||
@@ -651,6 +652,8 @@ const information = reactive({
|
||||
description: null as string | null,
|
||||
competitors: null as string | null,
|
||||
foundedAt: null as string | null,
|
||||
// Saisie brute invalide remontee par MalioDate (cf. foundedAtRaw, MUI-44).
|
||||
foundedAtRaw: '',
|
||||
employeesCount: null as string | null,
|
||||
revenueAmount: null as string | null,
|
||||
profitAmount: null as string | null,
|
||||
@@ -666,7 +669,8 @@ async function submitInformation(): Promise<void> {
|
||||
await api.patch(`/clients/${clientId.value}`, {
|
||||
description: information.description || null,
|
||||
competitors: information.competitors || null,
|
||||
foundedAt: information.foundedAt || null,
|
||||
// Saisie invalide prioritaire -> 422 back sur foundedAt (cf. foundedAtRaw).
|
||||
foundedAt: information.foundedAtRaw || information.foundedAt || null,
|
||||
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||
revenueAmount: information.revenueAmount || null,
|
||||
profitAmount: information.profitAmount || null,
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
:readonly="businessReadonly"
|
||||
:editable="true"
|
||||
:error="informationErrors.errors.foundedAt"
|
||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.employeesCount"
|
||||
@@ -370,7 +371,7 @@ import {
|
||||
mapAddressToDraft,
|
||||
mapRibToDraft,
|
||||
type SupplierDetail,
|
||||
} from '~/modules/commercial/utils/supplierConsultation'
|
||||
} from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||
import {
|
||||
buildAccountingPayload,
|
||||
buildAddressPayload,
|
||||
@@ -386,7 +387,7 @@ import {
|
||||
type InformationFormDraft,
|
||||
type MainFormDraft,
|
||||
type SupplierEditAbilities,
|
||||
} from '~/modules/commercial/utils/supplierEdit'
|
||||
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||
import {
|
||||
buildSupplierFormTabKeys,
|
||||
isAddressValid,
|
||||
@@ -396,7 +397,7 @@ import {
|
||||
isRibBlank,
|
||||
isRibComplete,
|
||||
isRibRequiredForPaymentType,
|
||||
} from '~/modules/commercial/utils/supplierFormRules'
|
||||
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||
import {
|
||||
emptyAddress,
|
||||
emptyContact,
|
||||
|
||||
@@ -263,7 +263,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
|
||||
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/supplierFormRules'
|
||||
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
import {
|
||||
canEditSupplier,
|
||||
@@ -280,7 +280,7 @@ import {
|
||||
showRestoreAction,
|
||||
type SelectOption,
|
||||
type SupplierDetail,
|
||||
} from '~/modules/commercial/utils/supplierConsultation'
|
||||
} from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||
import { emptyContact } from '~/modules/commercial/types/supplierForm'
|
||||
|
||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
:readonly="isValidated('information')"
|
||||
:editable="true"
|
||||
:error="informationErrors.errors.foundedAt"
|
||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.employeesCount"
|
||||
@@ -361,7 +362,7 @@ import {
|
||||
isRibComplete,
|
||||
isRibRequiredForPaymentType,
|
||||
lastFillableTabKey,
|
||||
} from '~/modules/commercial/utils/supplierFormRules'
|
||||
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||
import {
|
||||
buildAccountingPayload,
|
||||
buildAddressPayload,
|
||||
@@ -369,7 +370,7 @@ import {
|
||||
buildInformationPayload,
|
||||
buildMainPayload,
|
||||
buildRibPayload,
|
||||
} from '~/modules/commercial/utils/supplierEdit'
|
||||
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||
import {
|
||||
emptyAddress,
|
||||
emptyContact,
|
||||
@@ -549,6 +550,8 @@ const information = reactive({
|
||||
description: null as string | null,
|
||||
competitors: null as string | null,
|
||||
foundedAt: null as string | null,
|
||||
// Saisie brute invalide remontee par MalioDate (cf. foundedAtRaw, MUI-44).
|
||||
foundedAtRaw: '',
|
||||
employeesCount: null as string | null,
|
||||
revenueAmount: null as string | null,
|
||||
profitAmount: null as string | null,
|
||||
|
||||
+11
@@ -36,6 +36,7 @@ function informationDraft(overrides: Partial<InformationFormDraft> = {}): Inform
|
||||
description: 'desc',
|
||||
competitors: 'concurrents',
|
||||
foundedAt: '2010-05-01',
|
||||
foundedAtRaw: '',
|
||||
employeesCount: '42',
|
||||
revenueAmount: '1000000',
|
||||
profitAmount: '50000',
|
||||
@@ -140,6 +141,16 @@ describe('buildInformationPayload — scoping strict groupe client:write:informa
|
||||
expect(payload.description).toBeNull()
|
||||
expect(payload.directorName).toBeNull()
|
||||
})
|
||||
|
||||
it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => {
|
||||
// Saisie malformee : on transmet le texte brut tel quel pour declencher la
|
||||
// 422 back sur foundedAt (validation autoritaire du format, MUI-44).
|
||||
expect(buildInformationPayload(informationDraft({ foundedAt: null, foundedAtRaw: '32/13/2026' })).foundedAt)
|
||||
.toBe('32/13/2026')
|
||||
// Saisie valide : foundedAtRaw vide -> on envoie la date ISO.
|
||||
expect(buildInformationPayload(informationDraft({ foundedAt: '2010-05-01', foundedAtRaw: '' })).foundedAt)
|
||||
.toBe('2010-05-01')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => {
|
||||
+11
-2
@@ -11,7 +11,7 @@ import {
|
||||
mapMainDraft,
|
||||
resolveTabEditability,
|
||||
} from '../supplierEdit'
|
||||
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
|
||||
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm'
|
||||
|
||||
describe('buildMainPayload (groupe supplier:write:main)', () => {
|
||||
@@ -37,7 +37,7 @@ describe('buildMainPayload (groupe supplier:write:main)', () => {
|
||||
|
||||
describe('buildInformationPayload (groupe supplier:write:information)', () => {
|
||||
const base = {
|
||||
description: null, competitors: null, foundedAt: null, employeesCount: null,
|
||||
description: null, competitors: null, foundedAt: null, foundedAtRaw: '', employeesCount: null,
|
||||
revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null,
|
||||
}
|
||||
|
||||
@@ -48,6 +48,15 @@ describe('buildInformationPayload (groupe supplier:write:information)', () => {
|
||||
})
|
||||
expect(buildInformationPayload(base)).toMatchObject({ employeesCount: null, volumeForecast: null })
|
||||
})
|
||||
|
||||
it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => {
|
||||
// Saisie malformee transmise telle quelle pour declencher la 422 back (MUI-44).
|
||||
expect(buildInformationPayload({ ...base, foundedAt: null, foundedAtRaw: '32/13/2026' }).foundedAt)
|
||||
.toBe('32/13/2026')
|
||||
// Saisie valide : foundedAtRaw vide -> on envoie la date ISO.
|
||||
expect(buildInformationPayload({ ...base, foundedAt: '2008-04-01', foundedAtRaw: '' }).foundedAt)
|
||||
.toBe('2008-04-01')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildContactPayload (sous-ressource supplier_contact)', () => {
|
||||
+14
-3
@@ -20,14 +20,14 @@ import {
|
||||
iriOf,
|
||||
relationOf,
|
||||
type ClientDetail,
|
||||
} from '~/modules/commercial/utils/clientConsultation'
|
||||
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||
import {
|
||||
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
|
||||
blankEmptyRequired,
|
||||
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
||||
omitEmptyRequired,
|
||||
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
||||
} from '~/modules/commercial/utils/clientFormRules'
|
||||
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
||||
|
||||
/**
|
||||
@@ -53,6 +53,13 @@ export interface InformationFormDraft {
|
||||
competitors: string | null
|
||||
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
||||
foundedAt: string | null
|
||||
/**
|
||||
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
|
||||
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
|
||||
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
|
||||
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
|
||||
*/
|
||||
foundedAtRaw: string
|
||||
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
||||
employeesCount: string | null
|
||||
revenueAmount: string | null
|
||||
@@ -118,6 +125,8 @@ export function mapInformationDraft(client: ClientDetail): InformationFormDraft
|
||||
competitors: client.competitors ?? null,
|
||||
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
||||
foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null,
|
||||
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
|
||||
foundedAtRaw: '',
|
||||
employeesCount: client.employeesCount != null ? String(client.employeesCount) : null,
|
||||
revenueAmount: client.revenueAmount ?? null,
|
||||
profitAmount: client.profitAmount ?? null,
|
||||
@@ -191,7 +200,9 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
|
||||
return {
|
||||
description: information.description || null,
|
||||
competitors: information.competitors || null,
|
||||
foundedAt: information.foundedAt || null,
|
||||
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
|
||||
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
|
||||
foundedAt: information.foundedAtRaw || information.foundedAt || null,
|
||||
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||
revenueAmount: information.revenueAmount || null,
|
||||
profitAmount: information.profitAmount || null,
|
||||
+14
-3
@@ -17,8 +17,8 @@ import {
|
||||
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
||||
omitEmptyRequired,
|
||||
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
||||
} from '~/modules/commercial/utils/supplierFormRules'
|
||||
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
|
||||
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||
import type {
|
||||
SupplierAddressFormDraft,
|
||||
SupplierContactFormDraft,
|
||||
@@ -38,6 +38,13 @@ export interface InformationFormDraft {
|
||||
competitors: string | null
|
||||
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
||||
foundedAt: string | null
|
||||
/**
|
||||
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
|
||||
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
|
||||
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
|
||||
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
|
||||
*/
|
||||
foundedAtRaw: string
|
||||
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
||||
employeesCount: string | null
|
||||
revenueAmount: string | null
|
||||
@@ -95,6 +102,8 @@ export function mapInformationDraft(supplier: SupplierDetail): InformationFormDr
|
||||
competitors: supplier.competitors ?? null,
|
||||
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
||||
foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null,
|
||||
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
|
||||
foundedAtRaw: '',
|
||||
employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null,
|
||||
revenueAmount: supplier.revenueAmount ?? null,
|
||||
profitAmount: supplier.profitAmount ?? null,
|
||||
@@ -177,7 +186,9 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
|
||||
return {
|
||||
description: information.description || null,
|
||||
competitors: information.competitors || null,
|
||||
foundedAt: information.foundedAt || null,
|
||||
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
|
||||
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
|
||||
foundedAt: information.foundedAtRaw || information.foundedAt || null,
|
||||
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||
revenueAmount: information.revenueAmount || null,
|
||||
profitAmount: information.profitAmount || null,
|
||||
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : modal de confirmation cote parent. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.siteIris"
|
||||
:options="siteOptions"
|
||||
:label="t('technique.providers.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Categories de type PRESTATAIRE (>= 1 obligatoire, RG-3.09). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.categoryIris"
|
||||
:options="categoryOptions"
|
||||
:label="t('technique.providers.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('technique.providers.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('technique.providers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.postalCode"
|
||||
:label="t('technique.providers.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('technique.providers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.city"
|
||||
:label="t('technique.providers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
|
||||
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
||||
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('technique.providers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('technique.providers.form.address.streetNotFound')"
|
||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||
@search="onAddressSearch"
|
||||
@select="onAddressSelect"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('technique.providers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('technique.providers.form.address.streetComplement')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
|
||||
|
||||
// Masque code postal FR : 5 chiffres.
|
||||
const POSTAL_CODE_MASK = '#####'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Brouillon de l'adresse (v-model). */
|
||||
modelValue: ProviderAddressFormDraft
|
||||
/** Categories autorisees sur une adresse (type PRESTATAIRE). */
|
||||
categoryOptions: RefOption[]
|
||||
/** Sites Starseed disponibles. */
|
||||
siteOptions: RefOption[]
|
||||
/** Contacts deja saisis, rattachables a l'adresse. */
|
||||
contactOptions: RefOption[]
|
||||
/** Pays disponibles (France par defaut). */
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
readonly?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ProviderAddressFormDraft]
|
||||
'remove': []
|
||||
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
|
||||
'degraded': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const autocomplete = useAddressAutocomplete()
|
||||
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable).
|
||||
const degraded = ref(false)
|
||||
let unavailableNotified = false
|
||||
const banCityOptions = ref<RefOption[]>([])
|
||||
const banAddressOptions = ref<RefOption[]>([])
|
||||
|
||||
// Options ville effectives : on garantit que la ville courante figure toujours
|
||||
// dans la liste, sinon MalioSelect afficherait un champ vide en lecture seule.
|
||||
const cityOptions = computed<RefOption[]>(() => {
|
||||
const current = props.modelValue.city
|
||||
if (current && !banCityOptions.value.some(o => o.value === current)) {
|
||||
return [{ value: current, label: current }, ...banCityOptions.value]
|
||||
}
|
||||
return banCityOptions.value
|
||||
})
|
||||
|
||||
// Meme garantie pour le champ Adresse : la rue courante doit toujours figurer
|
||||
// dans les options, sinon MalioInputAutocomplete laisse le champ vide.
|
||||
const addressOptions = computed<RefOption[]>(() => {
|
||||
const current = props.modelValue.street
|
||||
if (current && !banAddressOptions.value.some(o => o.value === current)) {
|
||||
return [{ value: current, label: current }, ...banAddressOptions.value]
|
||||
}
|
||||
return banAddressOptions.value
|
||||
})
|
||||
const addressLoading = ref(false)
|
||||
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||
let lastAddressSuggestions: AddressSuggestion[] = []
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
||||
function notifyUnavailable(): void {
|
||||
if (!unavailableNotified) {
|
||||
unavailableNotified = true
|
||||
emit('degraded')
|
||||
}
|
||||
}
|
||||
|
||||
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville (RG-3.06). */
|
||||
async function onPostalCodeChange(value: string): Promise<void> {
|
||||
update('postalCode', value)
|
||||
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
if (digits.length < 5) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const suggestions = await autocomplete.searchCity(digits)
|
||||
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
|
||||
degraded.value = false
|
||||
}
|
||||
catch {
|
||||
degraded.value = true
|
||||
notifyUnavailable()
|
||||
}
|
||||
}
|
||||
|
||||
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
|
||||
async function onAddressSearch(query: string): Promise<void> {
|
||||
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400).
|
||||
if (query.trim().length < 3) {
|
||||
banAddressOptions.value = []
|
||||
return
|
||||
}
|
||||
addressLoading.value = true
|
||||
try {
|
||||
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
|
||||
const suggestions = await autocomplete.searchAddress(query, postalCode)
|
||||
lastAddressSuggestions = suggestions
|
||||
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
|
||||
}
|
||||
catch {
|
||||
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie.
|
||||
banAddressOptions.value = []
|
||||
notifyUnavailable()
|
||||
}
|
||||
finally {
|
||||
addressLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Selection d'une suggestion d'adresse → remplit rue + ville + CP. */
|
||||
function onAddressSelect(option: { label: string, value: string | number } | null): void {
|
||||
if (option === null) {
|
||||
return
|
||||
}
|
||||
const suggestion = lastAddressSuggestions.find(s => s.street === option.value)
|
||||
if (!suggestion) {
|
||||
update('street', String(option.value))
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
street: suggestion.street,
|
||||
city: suggestion.city,
|
||||
postalCode: suggestion.postalCode,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||
non supprimable (1er bloc) ou en lecture seule. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.lastName"
|
||||
:label="t('technique.providers.form.contact.lastName')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="model.firstName"
|
||||
:label="t('technique.providers.form.contact.firstName')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('technique.providers.form.contact.jobTitle')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
:model-value="model.email"
|
||||
:label="t('technique.providers.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('technique.providers.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('technique.providers.form.contact.addPhone')"
|
||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<!-- 2e numero : revele a la demande (max 2 telephones par contact). -->
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('technique.providers.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
|
||||
|
||||
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
|
||||
const PHONE_MASK = '## ## ## ## ##'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Brouillon du contact (v-model). */
|
||||
modelValue: ProviderContactFormDraft
|
||||
/** Affiche l'icone de suppression (1er bloc non supprimable). */
|
||||
removable?: boolean
|
||||
/** Bloc en lecture seule (onglet valide). */
|
||||
readonly?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ProviderContactFormDraft]
|
||||
'remove': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Alias local pour la lisibilite du template.
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof ProviderContactFormDraft>(field: K, value: ProviderContactFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/** Revele le 2e numero (max 1 secondaire, le « + » disparait). */
|
||||
function revealSecondaryPhone(): void {
|
||||
emit('update:modelValue', { ...props.modelValue, hasSecondaryPhone: true })
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref, computed } from 'vue'
|
||||
import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
|
||||
import ProviderAddressBlock from '../ProviderAddressBlock.vue'
|
||||
|
||||
// Mocks controlables du composable BAN (hoisted), reutilise tel quel du M1/M2.
|
||||
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
|
||||
searchCityMock: vi.fn(),
|
||||
searchAddressMock: vi.fn(),
|
||||
}))
|
||||
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
|
||||
useAddressAutocomplete: () => ({
|
||||
searchCity: searchCityMock,
|
||||
searchAddress: searchAddressMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('ref', ref)
|
||||
vi.stubGlobal('computed', computed)
|
||||
|
||||
// Stub de MalioInputAutocomplete : expose les `value` des options + allowCreate.
|
||||
const MalioInputAutocompleteStub = defineComponent({
|
||||
name: 'MalioInputAutocomplete',
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null], default: undefined },
|
||||
options: { type: Array as () => { value: string | number, label: string }[], default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
minSearchLength: { type: Number, default: 0 },
|
||||
label: { type: String, default: '' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
allowCreate: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ['update:modelValue', 'search', 'select'],
|
||||
setup(props) {
|
||||
return () => h('div', {
|
||||
'data-testid': 'addr-autocomplete',
|
||||
'data-options': JSON.stringify(props.options.map(o => o.value)),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<string, string>) {
|
||||
return mount(ProviderAddressBlock, {
|
||||
props: {
|
||||
modelValue: { ...emptyProviderAddress(), ...overrides },
|
||||
categoryOptions: [],
|
||||
siteOptions: [],
|
||||
contactOptions: [],
|
||||
countryOptions: [],
|
||||
...(errors ? { errors } : {}),
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioSelect: true,
|
||||
MalioSelectCheckbox: true,
|
||||
MalioInputText: true,
|
||||
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('ProviderAddressBlock — version simplifiee M3 (pas de type/bennes/triage)', () => {
|
||||
it('ne rend NI type d\'adresse, NI bennes, NI prestation de triage (difference M2)', () => {
|
||||
const wrapper = mountBlock()
|
||||
// Pas de stepper (bennes) ni de case a cocher (triage) dans le bloc M3.
|
||||
expect(wrapper.find('malio-input-number-stub').exists()).toBe(false)
|
||||
expect(wrapper.find('malio-checkbox-stub').exists()).toBe(false)
|
||||
// Aucun select ne porte le label « type d'adresse ».
|
||||
const hasAddressType = wrapper.findAll('malio-select-stub').some(
|
||||
el => el.attributes('label') === 'technique.providers.form.address.addressType',
|
||||
)
|
||||
expect(hasAddressType).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProviderAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
||||
it('affiche les erreurs serveur sur sites et categories (RG-3.05 / RG-3.09)', () => {
|
||||
const wrapper = mountBlock({}, {
|
||||
sites: 'Au moins un site est obligatoire.',
|
||||
categories: 'Au moins une catégorie est obligatoire.',
|
||||
})
|
||||
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
|
||||
const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites')
|
||||
const categoriesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.categories')
|
||||
|
||||
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
|
||||
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
|
||||
})
|
||||
|
||||
it('affiche l\'erreur serveur sur le code postal', () => {
|
||||
const wrapper = mountBlock({}, { postalCode: 'Code postal invalide.' })
|
||||
const field = wrapper.findAll('malio-input-text-stub').find(
|
||||
el => el.attributes('label') === 'technique.providers.form.address.postalCode',
|
||||
)
|
||||
expect(field?.attributes('error')).toBe('Code postal invalide.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProviderAddressBlock — autocompletion BAN (RG-3.06)', () => {
|
||||
beforeEach(() => {
|
||||
searchCityMock.mockReset()
|
||||
searchAddressMock.mockReset()
|
||||
})
|
||||
|
||||
it('n\'appelle pas la BAN en deca de 3 caracteres', async () => {
|
||||
const wrapper = mountBlock()
|
||||
wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab')
|
||||
await flushPromises()
|
||||
expect(searchAddressMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('relance la recherche apres une erreur (pas de bascule definitive)', async () => {
|
||||
searchAddressMock
|
||||
.mockRejectedValueOnce(new Error('BAN indisponible'))
|
||||
.mockResolvedValueOnce([
|
||||
{ label: '1 rue du Test, Châtellerault', street: '1 rue du Test', postalCode: '86100', city: 'Châtellerault' },
|
||||
])
|
||||
const wrapper = mountBlock()
|
||||
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||
|
||||
auto.vm.$emit('search', 'rue du test')
|
||||
await flushPromises()
|
||||
auto.vm.$emit('search', 'rue du teste')
|
||||
await flushPromises()
|
||||
|
||||
expect(searchAddressMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('cas degrade : la BAN echoue -> emet « degraded » une seule fois (RG-3.06)', async () => {
|
||||
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
|
||||
const wrapper = mountBlock()
|
||||
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||
|
||||
auto.vm.$emit('search', 'rue du test')
|
||||
await flushPromises()
|
||||
auto.vm.$emit('search', 'rue du teste')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('degraded')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
|
||||
const wrapper = mountBlock()
|
||||
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
|
||||
})
|
||||
|
||||
it('inclut la rue courante dans les options meme sans recherche BAN', () => {
|
||||
const wrapper = mountBlock({ street: '1 rue du Test' })
|
||||
const values = JSON.parse(wrapper.find('[data-testid="addr-autocomplete"]').attributes('data-options') ?? '[]')
|
||||
expect(values).toContain('1 rue du Test')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref, computed } from 'vue'
|
||||
import { emptyProviderContact } from '~/modules/technique/types/providerForm'
|
||||
import ProviderContactBlock from '../ProviderContactBlock.vue'
|
||||
|
||||
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('ref', ref)
|
||||
vi.stubGlobal('computed', computed)
|
||||
|
||||
/** Stub d'un champ Malio qui re-expose la prop `error` recue dans un data-* attribut. */
|
||||
function errorProbe(testid: string) {
|
||||
return defineComponent({
|
||||
name: `Probe-${testid}`,
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null], default: undefined },
|
||||
error: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('div', { 'data-testid': testid, 'data-error': props.error })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function mountBlock(errors?: Record<string, string>) {
|
||||
return mount(ProviderContactBlock, {
|
||||
props: {
|
||||
modelValue: emptyProviderContact(),
|
||||
...(errors ? { errors } : {}),
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioInputPhone: true,
|
||||
MalioInputText: errorProbe('contact-text'),
|
||||
MalioInputEmail: errorProbe('contact-email'),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('ProviderContactBlock — mapping erreur par champ (ERP-101)', () => {
|
||||
it('affiche l\'erreur serveur sur le champ email via la prop errors', () => {
|
||||
const wrapper = mountBlock({ email: 'L\'adresse email n\'est pas valide.' })
|
||||
expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('L\'adresse email n\'est pas valide.')
|
||||
})
|
||||
|
||||
it('laisse les champs sans erreur quand errors est absent', () => {
|
||||
const wrapper = mountBlock()
|
||||
expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,653 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Tests du workflow « Ajouter un prestataire » (M3 Technique, ERP-141).
|
||||
*
|
||||
* `useProviderForm` porte le formulaire principal (Nom + Categorie + Site) et
|
||||
* l'orchestration des onglets de creation. On verifie ici le CONTRAT propre a la
|
||||
* creation :
|
||||
* - RG-3.03 (front) : au moins un site requis ; RG-3.09 : au moins une categorie
|
||||
* -> POST bloque, erreurs inline, aucun appel reseau.
|
||||
* - POST /providers (groupe provider:write:main) : payload IRIs + Accept ld+json
|
||||
* + toast:false ; au succes, verrouillage + bascule sur l'onglet Contact +
|
||||
* reaffichage du nom normalise.
|
||||
* - 409 doublon (RG-3.10) -> erreur inline dediee sur companyName.
|
||||
* - 422 -> mapping inline par champ (propertyPath).
|
||||
* - Onglets : « Comptabilite » present uniquement avec accounting.view ;
|
||||
* completeTab deverrouille/avance et signale le dernier onglet.
|
||||
*/
|
||||
|
||||
const mockPost = vi.hoisted(() => vi.fn())
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
// Permissions comptables pilotables par test (presence/edition de l'onglet Comptabilite).
|
||||
const permState = vi.hoisted(() => ({ accountingView: false, accountingManage: false }))
|
||||
|
||||
vi.stubGlobal('useApi', () => ({
|
||||
get: vi.fn(),
|
||||
post: mockPost,
|
||||
put: vi.fn(),
|
||||
patch: mockPatch,
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('useToast', () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal('usePermissions', () => ({
|
||||
can: (perm: string) => {
|
||||
if (perm === 'technique.providers.accounting.view') return permState.accountingView
|
||||
if (perm === 'technique.providers.accounting.manage') return permState.accountingManage
|
||||
return true
|
||||
},
|
||||
}))
|
||||
|
||||
const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm')
|
||||
const { emptyProviderContact, emptyProviderAddress } = await import('~/modules/technique/types/providerForm')
|
||||
type ProviderForm = ReturnType<typeof useProviderForm>
|
||||
|
||||
const SITE_86 = '/api/sites/1'
|
||||
const CAT_MAINT = '/api/categories/7'
|
||||
|
||||
/** Accede a un bloc contact (cast : sous noUncheckedIndexedAccess l'index est optionnel). */
|
||||
function contactAt(form: ProviderForm, index = 0) {
|
||||
return form.contacts.value[index] ?? emptyProviderContact()
|
||||
}
|
||||
|
||||
/** Accede a un bloc adresse (idem). */
|
||||
function addressAt(form: ProviderForm, index = 0) {
|
||||
return form.addresses.value[index] ?? emptyProviderAddress()
|
||||
}
|
||||
|
||||
describe('useProviderForm', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
permState.accountingView = false
|
||||
permState.accountingManage = false
|
||||
})
|
||||
|
||||
it('front : formulaire principal vide -> erreurs sur nom + site + categorie, pas de POST', async () => {
|
||||
const form = useProviderForm()
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
|
||||
expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
|
||||
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
|
||||
expect(form.mainLocked.value).toBe(false)
|
||||
})
|
||||
|
||||
it('RG-3.03 (front) : un site present sans categorie n\'erre que sur categories', async () => {
|
||||
const form = useProviderForm()
|
||||
form.main.companyName = 'Maintenance Pro'
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
await form.submitMain()
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.mainErrors.errors.sites).toBeUndefined()
|
||||
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
|
||||
})
|
||||
|
||||
it('POST /providers avec IRIs + Accept ld+json, verrouille et bascule sur Contact', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 42, companyName: 'MAINTENANCE PRO' })
|
||||
const form = useProviderForm()
|
||||
form.main.companyName = 'Maintenance Pro'
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(true)
|
||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||
expect(url).toBe('/providers')
|
||||
expect(body).toEqual({
|
||||
companyName: 'Maintenance Pro',
|
||||
categories: [CAT_MAINT],
|
||||
sites: [SITE_86],
|
||||
})
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
|
||||
expect(form.providerId.value).toBe(42)
|
||||
// RG-3.11 : reaffiche le nom normalise (UPPERCASE) renvoye par le serveur.
|
||||
expect(form.main.companyName).toBe('MAINTENANCE PRO')
|
||||
expect(form.mainLocked.value).toBe(true)
|
||||
expect(form.activeTab.value).toBe('contact')
|
||||
expect(form.unlockedIndex.value).toBe(0)
|
||||
})
|
||||
|
||||
it('front : nom vide/espaces -> erreur inline sur companyName, pas de POST', async () => {
|
||||
const form = useProviderForm()
|
||||
form.main.companyName = ' '
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
|
||||
})
|
||||
|
||||
it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => {
|
||||
mockPost.mockRejectedValueOnce({ response: { status: 409 } })
|
||||
const form = useProviderForm()
|
||||
form.main.companyName = 'Doublon'
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany')
|
||||
expect(form.mainLocked.value).toBe(false)
|
||||
})
|
||||
|
||||
it('422 : mappe les violations serveur inline par champ', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'sites', message: 'Au moins un site est requis.' }] },
|
||||
},
|
||||
})
|
||||
const form = useProviderForm()
|
||||
form.main.companyName = 'X'
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(form.mainErrors.errors.sites).toBe('Au moins un site est requis.')
|
||||
})
|
||||
|
||||
it('onglet Comptabilite : absent sans accounting.view, present avec', () => {
|
||||
expect(buildProviderCreateTabKeys(false)).toEqual(['contact', 'address'])
|
||||
expect(buildProviderCreateTabKeys(true)).toEqual(['contact', 'address', 'accounting'])
|
||||
|
||||
permState.accountingView = true
|
||||
const form = useProviderForm()
|
||||
expect(form.tabKeys.value).toEqual(['contact', 'address', 'accounting'])
|
||||
})
|
||||
|
||||
it('completeTab : deverrouille/avance, et signale le dernier onglet du flux', () => {
|
||||
const form = useProviderForm()
|
||||
|
||||
// Contact -> Adresse (pas le dernier).
|
||||
expect(form.completeTab('contact')).toBe(false)
|
||||
expect(form.isValidated('contact')).toBe(true)
|
||||
expect(form.activeTab.value).toBe('address')
|
||||
expect(form.unlockedIndex.value).toBe(1)
|
||||
|
||||
// Adresse = dernier onglet remplissable (sans accounting.view) -> true.
|
||||
expect(form.completeTab('address')).toBe(true)
|
||||
expect(form.isValidated('address')).toBe(true)
|
||||
})
|
||||
|
||||
it('patchProvider : PATCH /providers/{id} en mode strict, no-op avant creation', async () => {
|
||||
const form = useProviderForm()
|
||||
|
||||
await form.patchProvider({ siren: '123456789' })
|
||||
expect(mockPatch).not.toHaveBeenCalled()
|
||||
|
||||
mockPost.mockResolvedValueOnce({ id: 9, companyName: 'ACME' })
|
||||
form.main.companyName = 'Acme'
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
await form.submitMain()
|
||||
|
||||
await form.patchProvider({ siren: '123456789' })
|
||||
expect(mockPatch).toHaveBeenCalledWith('/providers/9', { siren: '123456789' }, { toast: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useProviderForm — onglet Contact (ERP-142)', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
permState.accountingView = false
|
||||
permState.accountingManage = false
|
||||
})
|
||||
|
||||
/** Place le formulaire en etat « prestataire cree » (onglet Contact accessible). */
|
||||
function createdForm() {
|
||||
const form = useProviderForm()
|
||||
form.providerId.value = 7
|
||||
return form
|
||||
}
|
||||
|
||||
it('RG-3.04 : « + Nouveau contact » desactive tant que le dernier bloc est vide', () => {
|
||||
const form = createdForm()
|
||||
expect(form.canAddContact.value).toBe(false)
|
||||
|
||||
// addContact est un no-op tant que le bloc est vide.
|
||||
form.addContact()
|
||||
expect(form.contacts.value).toHaveLength(1)
|
||||
|
||||
contactAt(form).lastName = 'Doe'
|
||||
expect(form.canAddContact.value).toBe(true)
|
||||
form.addContact()
|
||||
expect(form.contacts.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('removeContact retire le bloc et son erreur de ligne', () => {
|
||||
const form = createdForm()
|
||||
contactAt(form).lastName = 'Doe'
|
||||
form.addContact()
|
||||
form.contactErrors.value = [{}, { lastName: 'x' }]
|
||||
|
||||
form.removeContact(1)
|
||||
expect(form.contacts.value).toHaveLength(1)
|
||||
expect(form.contactErrors.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('submitContacts : POST des nouveaux, capture id + IRI, finalise l\'onglet', async () => {
|
||||
mockPost.mockResolvedValueOnce({ '@id': '/api/provider_contacts/55', id: 55 })
|
||||
const form = createdForm()
|
||||
contactAt(form).lastName = 'Doe'
|
||||
|
||||
const ok = await form.submitContacts(vi.fn())
|
||||
|
||||
expect(ok).toBe(true)
|
||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||
expect(url).toBe('/providers/7/contacts')
|
||||
expect(body).toMatchObject({ lastName: 'Doe' })
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
expect(contactAt(form).id).toBe(55)
|
||||
expect(contactAt(form).iri).toBe('/api/provider_contacts/55')
|
||||
expect(form.isValidated('contact')).toBe(true)
|
||||
})
|
||||
|
||||
it('submitContacts : PATCH des contacts existants sur /provider_contacts/{id}', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
contactAt(form).id = 55
|
||||
contactAt(form).lastName = 'Doe'
|
||||
|
||||
await form.submitContacts(vi.fn())
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith('/provider_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false })
|
||||
})
|
||||
|
||||
it('RG-3.12 : onglet vide -> soumet l\'amorce pour declencher la 422 firstName inline', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
const form = createdForm()
|
||||
|
||||
const ok = await form.submitContacts(vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||
expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.')
|
||||
expect(form.isValidated('contact')).toBe(false)
|
||||
})
|
||||
|
||||
it('mappe les erreurs 422 PAR LIGNE (le bloc 2 echoue, le bloc 1 passe)', async () => {
|
||||
mockPost
|
||||
.mockResolvedValueOnce({ '@id': '/api/provider_contacts/1', id: 1 })
|
||||
.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'email', message: 'L\'adresse email n\'est pas valide.' }] },
|
||||
},
|
||||
})
|
||||
const form = createdForm()
|
||||
contactAt(form).lastName = 'Doe'
|
||||
form.addContact()
|
||||
contactAt(form, 1).email = 'invalide'
|
||||
|
||||
const ok = await form.submitContacts(vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.contactErrors.value[0]).toBeUndefined()
|
||||
expect(form.contactErrors.value[1]?.email).toBe('L\'adresse email n\'est pas valide.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useProviderForm — onglet Adresse (ERP-143)', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
permState.accountingView = false
|
||||
permState.accountingManage = false
|
||||
})
|
||||
|
||||
/** Place le formulaire en etat « prestataire cree » (onglet Adresse accessible). */
|
||||
function createdForm() {
|
||||
const form = useProviderForm()
|
||||
form.providerId.value = 7
|
||||
return form
|
||||
}
|
||||
|
||||
/** Remplit un bloc adresse valide (site + categorie + scalaires requis). */
|
||||
function fillValidAddress(form: ProviderForm, index = 0): void {
|
||||
const a = addressAt(form, index)
|
||||
a.siteIris = [SITE_86]
|
||||
a.categoryIris = [CAT_MAINT]
|
||||
a.postalCode = '86100'
|
||||
a.city = 'Châtellerault'
|
||||
a.street = '1 rue du Test'
|
||||
}
|
||||
|
||||
it('RG-3.05 : « + Nouvelle adresse » desactive tant que site + categorie manquent', () => {
|
||||
const form = createdForm()
|
||||
expect(form.canAddAddress.value).toBe(false)
|
||||
|
||||
// no-op tant que l'adresse n'est pas valide.
|
||||
form.addAddress()
|
||||
expect(form.addresses.value).toHaveLength(1)
|
||||
|
||||
addressAt(form).siteIris = [SITE_86]
|
||||
expect(form.canAddAddress.value).toBe(false) // categorie manquante
|
||||
addressAt(form).categoryIris = [CAT_MAINT]
|
||||
expect(form.canAddAddress.value).toBe(true)
|
||||
form.addAddress()
|
||||
expect(form.addresses.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('removeAddress retire le bloc et son erreur de ligne', () => {
|
||||
const form = createdForm()
|
||||
fillValidAddress(form)
|
||||
form.addAddress()
|
||||
form.addressErrors.value = [{}, { city: 'x' }]
|
||||
|
||||
form.removeAddress(1)
|
||||
expect(form.addresses.value).toHaveLength(1)
|
||||
expect(form.addressErrors.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('submitAddresses : POST des nouvelles, capture l\'id, finalise l\'onglet', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 88 })
|
||||
const form = createdForm()
|
||||
fillValidAddress(form)
|
||||
|
||||
const ok = await form.submitAddresses(vi.fn())
|
||||
|
||||
expect(ok).toBe(true)
|
||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||
expect(url).toBe('/providers/7/addresses')
|
||||
expect(body).toMatchObject({ sites: [SITE_86], categories: [CAT_MAINT], city: 'Châtellerault' })
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
expect(addressAt(form).id).toBe(88)
|
||||
expect(form.isValidated('address')).toBe(true)
|
||||
})
|
||||
|
||||
it('submitAddresses : PATCH des adresses existantes sur /provider_addresses/{id}', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
fillValidAddress(form)
|
||||
addressAt(form).id = 88
|
||||
|
||||
await form.submitAddresses(vi.fn())
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith('/provider_addresses/88', expect.objectContaining({ sites: [SITE_86] }), { toast: false })
|
||||
})
|
||||
|
||||
it('mappe les erreurs 422 PAR LIGNE et ne finalise pas l\'onglet', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'city', message: 'La ville est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
const form = createdForm()
|
||||
fillValidAddress(form)
|
||||
|
||||
const ok = await form.submitAddresses(vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire.')
|
||||
expect(form.isValidated('address')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useProviderForm — onglet Comptabilite (ERP-144)', () => {
|
||||
const TVA = '/api/tva_modes/1'
|
||||
const DELAY = '/api/payment_delays/1'
|
||||
const TYPE = '/api/payment_types/3'
|
||||
const BANK = '/api/banks/2'
|
||||
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
permState.accountingView = true
|
||||
permState.accountingManage = true
|
||||
})
|
||||
|
||||
/** Prestataire cree, onglet Comptabilite editable (view + manage). */
|
||||
function createdForm() {
|
||||
const form = useProviderForm()
|
||||
form.providerId.value = 7
|
||||
return form
|
||||
}
|
||||
|
||||
/** Remplit les scalaires comptables communs. */
|
||||
function fillScalars(form: ProviderForm): void {
|
||||
form.accounting.siren = '123456789'
|
||||
form.accounting.accountNumber = '4010'
|
||||
form.accounting.tvaModeIri = TVA
|
||||
form.accounting.nTva = 'FR123'
|
||||
form.accounting.paymentDelayIri = DELAY
|
||||
form.accounting.paymentTypeIri = TYPE
|
||||
}
|
||||
|
||||
it('lecture seule sans accounting.manage (Compta consultation / autres roles)', () => {
|
||||
permState.accountingManage = false
|
||||
const form = createdForm()
|
||||
expect(form.accountingReadonly.value).toBe(true)
|
||||
|
||||
permState.accountingManage = true
|
||||
const form2 = createdForm()
|
||||
expect(form2.accountingReadonly.value).toBe(false)
|
||||
})
|
||||
|
||||
it('RG-3.07 : setPaymentType(VIREMENT) garde la banque ; un autre type la vide', () => {
|
||||
const form = createdForm()
|
||||
form.accounting.bankIri = BANK
|
||||
|
||||
// Type VIREMENT -> banque requise, conservee.
|
||||
form.setPaymentType(TYPE, true, false)
|
||||
expect(form.accounting.bankIri).toBe(BANK)
|
||||
|
||||
// Type non-VIREMENT -> banque videe (sans objet).
|
||||
form.setPaymentType(TYPE, false, false)
|
||||
expect(form.accounting.bankIri).toBeNull()
|
||||
})
|
||||
|
||||
it('RG-3.08 : setPaymentType(LCR) garantit au moins un bloc RIB', () => {
|
||||
const form = createdForm()
|
||||
expect(form.ribs.value).toHaveLength(0)
|
||||
|
||||
form.setPaymentType(TYPE, false, true)
|
||||
expect(form.ribs.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('« + RIB » desactive tant que le dernier RIB est incomplet (RG-3.08)', () => {
|
||||
const form = createdForm()
|
||||
form.setPaymentType(TYPE, false, true)
|
||||
expect(form.canAddRib.value).toBe(false)
|
||||
|
||||
const rib = form.ribs.value[0]
|
||||
if (rib) {
|
||||
rib.label = 'Compte'
|
||||
rib.bic = 'BNPAFRPP'
|
||||
rib.iban = 'FR76...'
|
||||
}
|
||||
expect(form.canAddRib.value).toBe(true)
|
||||
})
|
||||
|
||||
it('VIREMENT : PATCH des scalaires avec banque, aucun appel RIB', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
fillScalars(form)
|
||||
form.accounting.bankIri = BANK
|
||||
|
||||
const ok = await form.submitAccounting(true, false, vi.fn())
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/providers/7',
|
||||
expect.objectContaining({ paymentType: TYPE, bank: BANK, siren: '123456789' }),
|
||||
{ toast: false },
|
||||
)
|
||||
expect(form.isValidated('accounting')).toBe(true)
|
||||
})
|
||||
|
||||
it('hors VIREMENT : la banque part a null dans le payload (RG-3.07)', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
fillScalars(form)
|
||||
form.accounting.bankIri = BANK // residu : doit etre ignore (isBankRequired=false)
|
||||
|
||||
await form.submitAccounting(false, false, vi.fn())
|
||||
|
||||
const body = mockPatch.mock.calls[0]?.[1] as Record<string, unknown>
|
||||
expect(body.bank).toBeNull()
|
||||
})
|
||||
|
||||
it('LCR : POST des RIB AVANT le PATCH des scalaires (ordre RG-3.08)', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 50 })
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
fillScalars(form)
|
||||
form.setPaymentType(TYPE, false, true)
|
||||
const rib = form.ribs.value[0]
|
||||
if (rib) {
|
||||
rib.label = 'Compte'
|
||||
rib.bic = 'BNPAFRPP'
|
||||
rib.iban = 'FR76...'
|
||||
}
|
||||
|
||||
const ok = await form.submitAccounting(false, true, vi.fn())
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/providers/7/ribs',
|
||||
expect.objectContaining({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }),
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
expect(form.ribs.value[0]?.id).toBe(50)
|
||||
// Le PATCH des scalaires intervient APRES la creation du RIB.
|
||||
expect(mockPatch).toHaveBeenCalledWith('/providers/7', expect.any(Object), { toast: false })
|
||||
})
|
||||
|
||||
it('422 sur les scalaires (bank) : mapping inline, onglet non finalise', async () => {
|
||||
mockPatch.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'bank', message: 'La banque est obligatoire pour un virement.' }] },
|
||||
},
|
||||
})
|
||||
const form = createdForm()
|
||||
fillScalars(form)
|
||||
|
||||
const ok = await form.submitAccounting(true, false, vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.accountingErrors.errors.bank).toBe('La banque est obligatoire pour un virement.')
|
||||
expect(form.isValidated('accounting')).toBe(false)
|
||||
})
|
||||
|
||||
it('LCR : 422 RIB par ligne -> pas de PATCH des scalaires', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'iban', message: 'L\'IBAN est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
const form = createdForm()
|
||||
fillScalars(form)
|
||||
form.setPaymentType(TYPE, false, true)
|
||||
const rib = form.ribs.value[0]
|
||||
if (rib) {
|
||||
rib.label = 'Compte'
|
||||
rib.bic = 'BNPAFRPP'
|
||||
}
|
||||
|
||||
const ok = await form.submitAccounting(false, true, vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.ribErrors.value[0]?.iban).toBe('L\'IBAN est obligatoire.')
|
||||
expect(mockPatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useProviderForm — modification (ERP-145)', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
permState.accountingView = false
|
||||
permState.accountingManage = false
|
||||
})
|
||||
|
||||
it('editMode : completeTab ne verrouille pas et ne bascule pas d\'onglet', () => {
|
||||
const form = useProviderForm()
|
||||
form.editMode.value = true
|
||||
form.activeTab.value = 'contact'
|
||||
|
||||
expect(form.completeTab('contact')).toBe(false)
|
||||
expect(form.isValidated('contact')).toBe(false)
|
||||
expect(form.activeTab.value).toBe('contact')
|
||||
})
|
||||
|
||||
it('updateMain : PATCH /providers/{id} sur le groupe principal (pas de POST)', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ id: 7, companyName: 'MAINTENANCE PRO' })
|
||||
const form = useProviderForm()
|
||||
form.providerId.value = 7
|
||||
form.main.companyName = 'Maintenance Pro'
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
const ok = await form.updateMain()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/providers/7',
|
||||
{ companyName: 'Maintenance Pro', categories: [CAT_MAINT], sites: [SITE_86] },
|
||||
{ toast: false },
|
||||
)
|
||||
// Reaffiche le nom normalise renvoye par le serveur.
|
||||
expect(form.main.companyName).toBe('MAINTENANCE PRO')
|
||||
})
|
||||
|
||||
it('updateMain : RG-3.03 front -> bloque le PATCH sans site', async () => {
|
||||
const form = useProviderForm()
|
||||
form.providerId.value = 7
|
||||
form.main.companyName = 'X'
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
|
||||
const ok = await form.updateMain()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(mockPatch).not.toHaveBeenCalled()
|
||||
expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
|
||||
})
|
||||
|
||||
it('updateMain : 409 doublon -> erreur inline sur companyName', async () => {
|
||||
mockPatch.mockRejectedValueOnce({ response: { status: 409 } })
|
||||
const form = useProviderForm()
|
||||
form.providerId.value = 7
|
||||
form.main.companyName = 'Doublon'
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
const ok = await form.updateMain()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useProvidersRepository, type Provider } from '../useProvidersRepository'
|
||||
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
/**
|
||||
* Tests du repertoire prestataires (ERP-140).
|
||||
*
|
||||
* `useProvidersRepository` est une fine enveloppe de `usePaginatedList<Provider>`
|
||||
* sur `/providers`. Les invariants generiques de pagination sont deja couverts
|
||||
* par `usePaginatedList.test.ts` ; on verifie ici le CONTRAT propre au repertoire :
|
||||
* - la ressource ciblee est bien `/providers`
|
||||
* - l'enveloppe Hydra (member / totalItems) est consommee
|
||||
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
|
||||
* renvoie un tableau plat sans pagination)
|
||||
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `includeArchived` n'est envoye
|
||||
* tant que l'utilisateur ne coche pas le filtre (le back masque alors les
|
||||
* archives) ; le filtre `includeArchived` est bien transmis une fois applique.
|
||||
*/
|
||||
describe('useProvidersRepository', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
})
|
||||
|
||||
/** Une page de prestataires Hydra, avec categories[] et sites[] embarques. */
|
||||
const PAGE: Provider[] = [
|
||||
{
|
||||
id: 1,
|
||||
companyName: 'ACME MAINTENANCE',
|
||||
categories: [{ code: 'MAINTENANCE_INDUSTRIELLE', name: 'Maintenance industrielle' }],
|
||||
sites: [{ id: 4, name: 'Chatellerault', color: '#056CF2' }],
|
||||
updatedAt: '2026-06-15T08:12:01+02:00',
|
||||
isArchived: false,
|
||||
},
|
||||
]
|
||||
|
||||
it('cible /providers, consomme l\'enveloppe Hydra et envoie l\'Accept ld+json', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useProvidersRepository()
|
||||
|
||||
await repo.fetch()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||
expect(url).toBe('/providers')
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
||||
expect(opts).toMatchObject({
|
||||
toast: false,
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
})
|
||||
expect(repo.items.value).toEqual(PAGE)
|
||||
expect(repo.totalItems.value).toBe(1)
|
||||
})
|
||||
|
||||
it('exclut les archives par defaut : aucun includeArchived au premier fetch', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useProvidersRepository()
|
||||
|
||||
await repo.fetch()
|
||||
|
||||
const query = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
||||
expect(query.includeArchived).toBeUndefined()
|
||||
})
|
||||
|
||||
it('transmet includeArchived une fois le filtre applique (retour page 1)', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useProvidersRepository()
|
||||
await repo.fetch()
|
||||
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
await repo.setFilters({ includeArchived: true })
|
||||
|
||||
expect(repo.currentPage.value).toBe(1)
|
||||
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(query.includeArchived).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ref } from 'vue'
|
||||
import type { ProviderDetail } from '~/modules/technique/utils/forms/providerDetail'
|
||||
|
||||
/**
|
||||
* Chargement et actions d'archivage d'un prestataire unique (ecrans Consultation /
|
||||
* Modification, ERP-145). Miroir de `useSupplier` (M2). Lit le detail embarque via
|
||||
* `GET /api/providers/{id}` (contacts / adresses + leurs sous-collections / ribs
|
||||
* sous `provider:item:read` / `provider:read:accounting`) — une SEULE requete
|
||||
* peuple les deux ecrans (embed borne, pas de N+1).
|
||||
*
|
||||
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload Hydra
|
||||
* complet (avec les `@id` des relations embarquees, indispensables au pre-remplissage).
|
||||
*
|
||||
* Etat 100 % local a l'instance (refs). Les erreurs d'archivage / restauration
|
||||
* (notamment le 409 d'homonyme actif a la restauration) sont PROPAGEES a l'appelant,
|
||||
* qui decide du toast a afficher.
|
||||
*/
|
||||
export function useProvider(id: number | string) {
|
||||
const api = useApi()
|
||||
|
||||
const provider = ref<ProviderDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
|
||||
/** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
|
||||
function fetchDetail(): Promise<ProviderDetail> {
|
||||
return api.get<ProviderDetail>(
|
||||
`/providers/${id}`,
|
||||
{},
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
}
|
||||
|
||||
/** Charge le detail du prestataire. En cas d'echec : `error = true`, `provider = null`. */
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
provider.value = await fetchDetail()
|
||||
}
|
||||
catch {
|
||||
error.value = true
|
||||
provider.value = null
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bascule l'archivage (PATCH `isArchived` SEUL — groupe provider:write:archive ;
|
||||
* tout autre champ => 422), puis RECHARGE le detail complet : la reponse du PATCH
|
||||
* ne porte que `provider:read` (ni l'embed des sous-collections ni les libelles
|
||||
* comptables), un simple merge laisserait l'affichage incoherent. Toute erreur est
|
||||
* propagee a l'appelant AVANT le rechargement.
|
||||
*/
|
||||
async function setArchived(isArchived: boolean): Promise<void> {
|
||||
await api.patch(`/providers/${id}`, { isArchived }, { toast: false })
|
||||
provider.value = await fetchDetail()
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
loading,
|
||||
error,
|
||||
load,
|
||||
archive: () => setArchived(true),
|
||||
restore: () => setArchived(false),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
import { computed, reactive, ref, type Ref } from 'vue'
|
||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||
import { mapViolationsToRecord } from '~/shared/utils/api'
|
||||
import {
|
||||
emptyProviderAccounting,
|
||||
emptyProviderAddress,
|
||||
emptyProviderContact,
|
||||
emptyProviderMain,
|
||||
emptyProviderRib,
|
||||
type ProviderAccountingDraft,
|
||||
type ProviderAddressFormDraft,
|
||||
type ProviderAddressResponse,
|
||||
type ProviderContactFormDraft,
|
||||
type ProviderContactResponse,
|
||||
type ProviderMainDraft,
|
||||
type ProviderMainResponse,
|
||||
type ProviderRibFormDraft,
|
||||
type ProviderRibResponse,
|
||||
} from '~/modules/technique/types/providerForm'
|
||||
import {
|
||||
buildProviderContactPayload,
|
||||
isProviderContactBlank,
|
||||
isProviderContactNamed,
|
||||
} from '~/modules/technique/utils/forms/providerContact'
|
||||
import {
|
||||
buildProviderAddressPayload,
|
||||
isProviderAddressValid,
|
||||
} from '~/modules/technique/utils/forms/providerAddress'
|
||||
import {
|
||||
buildProviderAccountingPayload,
|
||||
buildProviderRibPayload,
|
||||
isRibBlank,
|
||||
isRibComplete,
|
||||
} from '~/modules/technique/utils/forms/providerAccounting'
|
||||
|
||||
/**
|
||||
* Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) —
|
||||
* miroir conceptuel de la logique de creation fournisseur (M2), extraite ici en
|
||||
* composable.
|
||||
*
|
||||
* Particularites M3 (cf. spec-front § « Ecran Ajouter ») :
|
||||
* - PAS d'onglet « Information » : le formulaire principal est minimal (Nom +
|
||||
* Categorie + Site).
|
||||
* - Selecteur de site SUR le formulaire principal (RG-3.03, relation directe
|
||||
* `provider.sites`).
|
||||
* - Creation incrementale par onglets (Contact · Adresse · Comptabilite) :
|
||||
* POST principal puis PATCH partiels par groupe de serialisation
|
||||
* (`provider:write:*`, mode strict — spec-back § 2.10). Le contenu des onglets
|
||||
* arrive aux tickets ERP-142 → 144 ; ce composable pose le POST principal et
|
||||
* l'orchestration des onglets.
|
||||
*
|
||||
* Etat 100 % local a l'instance (refs/reactive) — aucune persistance URL.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cles des onglets du FLUX DE CREATION. Pas d'onglet « Information » au M3 ;
|
||||
* « Rapports » / « Echanges » n'apparaissent qu'en consultation/modification.
|
||||
* L'onglet « Comptabilite » n'est present que pour les roles qui peuvent le voir
|
||||
* (`technique.providers.accounting.view` — Admin, Compta).
|
||||
*/
|
||||
export function buildProviderCreateTabKeys(canAccountingView: boolean): string[] {
|
||||
return canAccountingView
|
||||
? ['contact', 'address', 'accounting']
|
||||
: ['contact', 'address']
|
||||
}
|
||||
|
||||
export function useProviderForm() {
|
||||
const api = useApi()
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
|
||||
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
||||
const mainErrors = useFormErrors()
|
||||
|
||||
// ── Etat du prestataire cree ────────────────────────────────────────────
|
||||
const providerId = ref<number | null>(null)
|
||||
const mainLocked = ref(false)
|
||||
const mainSubmitting = ref(false)
|
||||
const tabSubmitting = ref(false)
|
||||
|
||||
// ── Formulaire principal ──────────────────────────────────────────────────
|
||||
const main = reactive<ProviderMainDraft>(emptyProviderMain())
|
||||
|
||||
// ── Onglets : ordre + gating progressif ───────────────────────────────────
|
||||
const canAccountingView = computed(() => can('technique.providers.accounting.view'))
|
||||
const canAccountingManage = computed(() => can('technique.providers.accounting.manage'))
|
||||
const tabKeys = computed(() => buildProviderCreateTabKeys(canAccountingView.value))
|
||||
|
||||
// Index du dernier onglet deverrouille (-1 tant que le prestataire n'est pas cree).
|
||||
const unlockedIndex = ref(-1)
|
||||
const activeTab = ref<string>('contact')
|
||||
// Onglets valides (passent en lecture seule).
|
||||
const validated = reactive<Record<string, boolean>>({})
|
||||
// Mode MODIFICATION (ERP-145) : navigation libre, pas de verrouillage ni de
|
||||
// bascule automatique d'onglet a la validation (cf. completeTab).
|
||||
const editMode = ref(false)
|
||||
|
||||
function isValidated(key: string): boolean {
|
||||
return validated[key] === true
|
||||
}
|
||||
|
||||
function tabIndex(key: string): number {
|
||||
return tabKeys.value.indexOf(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation FRONT du formulaire principal : RG-3.03 (>= 1 site) et RG-3.09
|
||||
* (>= 1 categorie). Pose les erreurs inline et retourne false si invalide.
|
||||
* Le back reste la couche autoritaire (ERP-101) ; ce pre-check evite un
|
||||
* aller-retour inutile et porte la garantie RG-3.03 cote front.
|
||||
*/
|
||||
function validateMainFront(): boolean {
|
||||
let valid = true
|
||||
if (!main.companyName?.trim()) {
|
||||
mainErrors.setError('companyName', t('technique.providers.form.errors.nameRequired'))
|
||||
valid = false
|
||||
}
|
||||
if (main.siteIris.length === 0) {
|
||||
mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired'))
|
||||
valid = false
|
||||
}
|
||||
if (main.categoryIris.length === 0) {
|
||||
mainErrors.setError('categories', t('technique.providers.form.errors.categoryRequired'))
|
||||
valid = false
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload du POST principal (groupe `provider:write:main`). `companyName` est
|
||||
* omis s'il est vide afin que la 422 porte la violation NotBlank (RG-3.11) sur
|
||||
* le champ plutot qu'une erreur de type. Les relations M2M partent en IRI.
|
||||
*/
|
||||
function buildMainPayload(): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
categories: [...main.categoryIris],
|
||||
sites: [...main.siteIris],
|
||||
}
|
||||
if (main.companyName?.trim()) {
|
||||
payload.companyName = main.companyName
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /providers (groupe `provider:write:main`). Pre-check front RG-3.03/3.09,
|
||||
* puis creation. Au succes : verrouille le bloc principal, deverrouille le 1er
|
||||
* onglet et bascule sur « Contact ». Retourne true si cree, false sinon.
|
||||
*/
|
||||
async function submitMain(): Promise<boolean> {
|
||||
if (mainSubmitting.value) return false
|
||||
mainErrors.clearErrors()
|
||||
if (!validateMainFront()) return false
|
||||
|
||||
mainSubmitting.value = true
|
||||
try {
|
||||
const created = await api.post<ProviderMainResponse>('/providers', buildMainPayload(), {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
|
||||
providerId.value = created.id
|
||||
// Reaffiche la valeur normalisee renvoyee par le serveur (UPPERCASE, RG-3.11).
|
||||
main.companyName = created.companyName ?? main.companyName
|
||||
|
||||
mainLocked.value = true
|
||||
unlockedIndex.value = 0
|
||||
activeTab.value = tabKeys.value[0] ?? 'contact'
|
||||
toast.success({ title: t('technique.providers.toast.createSuccess') })
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
// 409 = doublon de nom (RG-3.10) → erreur inline dediee + toast ;
|
||||
// 422 → mapping inline par champ ; autre → toast de fallback (ERP-101).
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) {
|
||||
const message = t('technique.providers.form.duplicateCompany')
|
||||
mainErrors.setError('companyName', message)
|
||||
toast.error({ title: t('technique.providers.toast.error'), message })
|
||||
}
|
||||
else {
|
||||
mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
mainSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH partiel du prestataire (mode strict : un seul groupe de serialisation
|
||||
* par appel — spec-back § 2.10). Sert l'onglet Comptabilite a champs scalaires
|
||||
* (ERP-144) ; les onglets Contact/Adresse passent par leurs sous-ressources
|
||||
* (POST/PATCH par ligne, ERP-142/143). No-op tant que le prestataire n'existe pas.
|
||||
*/
|
||||
async function patchProvider(payload: Record<string, unknown>): Promise<void> {
|
||||
if (providerId.value === null) return
|
||||
await api.patch(`/providers/${providerId.value}`, payload, { toast: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* MODIFICATION du bloc principal (ERP-145) : PATCH /providers/{id} sur le groupe
|
||||
* provider:write:main (nom + categories + sites). Pre-check front RG-3.03/3.09,
|
||||
* 409 doublon de nom (RG-3.10) et 422 mappes inline comme a la creation. A la
|
||||
* difference de `submitMain`, ne verrouille rien et ne bascule pas d'onglet (la
|
||||
* navigation est libre en modification). Retourne true si le PATCH a reussi.
|
||||
*/
|
||||
async function updateMain(): Promise<boolean> {
|
||||
if (providerId.value === null || mainSubmitting.value) return false
|
||||
mainErrors.clearErrors()
|
||||
if (!validateMainFront()) return false
|
||||
|
||||
mainSubmitting.value = true
|
||||
try {
|
||||
const updated = await api.patch<ProviderMainResponse>(
|
||||
`/providers/${providerId.value}`,
|
||||
buildMainPayload(),
|
||||
{ toast: false },
|
||||
)
|
||||
main.companyName = updated.companyName ?? main.companyName
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) {
|
||||
const message = t('technique.providers.form.duplicateCompany')
|
||||
mainErrors.setError('companyName', message)
|
||||
toast.error({ title: t('technique.providers.toast.error'), message })
|
||||
}
|
||||
else {
|
||||
mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
mainSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque un onglet valide (passe en lecture seule), deverrouille et avance a
|
||||
* l'onglet suivant. Retourne true si c'etait le dernier onglet du flux
|
||||
* (creation terminee), false sinon.
|
||||
*/
|
||||
function completeTab(key: string): boolean {
|
||||
// En modification : navigation libre, l'onglet reste editable apres validation.
|
||||
if (editMode.value) {
|
||||
return false
|
||||
}
|
||||
validated[key] = true
|
||||
const index = tabIndex(key)
|
||||
const next = tabKeys.value[index + 1]
|
||||
if (next === undefined) {
|
||||
return true
|
||||
}
|
||||
unlockedIndex.value = Math.max(unlockedIndex.value, index + 1)
|
||||
activeTab.value = next
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX :
|
||||
* on n'arrete pas au premier bloc en echec (decision ERP-101). Reinitialise la
|
||||
* cible, tente chaque ligne via `saveRow`, mappe les 422 inline ou delegue le
|
||||
* fallback a `onUnmappedError`. `shouldSkip` ignore les amorces vides. Retourne
|
||||
* true si au moins un bloc a echoue. Miroir de `useSupplierFormErrors.submitRows`.
|
||||
*/
|
||||
async function submitRows<T>(
|
||||
rows: T[],
|
||||
target: Ref<Record<string, string>[]>,
|
||||
saveRow: (row: T, index: number) => Promise<void>,
|
||||
onUnmappedError: (error: unknown, index: number) => void,
|
||||
shouldSkip?: (row: T, index: number) => boolean,
|
||||
): Promise<boolean> {
|
||||
target.value = []
|
||||
let hasError = false
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
const row = rows[index] as T
|
||||
if (shouldSkip?.(row, index)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await saveRow(row, index)
|
||||
}
|
||||
catch (error) {
|
||||
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
|
||||
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
|
||||
if (Object.keys(mapped).length > 0) {
|
||||
target.value[index] = mapped
|
||||
}
|
||||
else {
|
||||
onUnmappedError(error, index)
|
||||
}
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
return hasError
|
||||
}
|
||||
|
||||
// ── Onglet Contact (ERP-142) ──────────────────────────────────────────────
|
||||
const contacts = ref<ProviderContactFormDraft[]>([emptyProviderContact()])
|
||||
// Erreurs 422 par ligne (alignees sur l'index du v-for), peuplees par submitRows.
|
||||
const contactErrors = ref<Record<string, string>[]>([])
|
||||
|
||||
// « + Nouveau contact » desactive tant que le dernier bloc n'a pas de nom OU
|
||||
// prenom (RG-3.04, aligne M1/M2 — fonction/tel/email seuls ne suffisent pas).
|
||||
const canAddContact = computed(() => {
|
||||
const last = contacts.value[contacts.value.length - 1]
|
||||
return last !== undefined && isProviderContactNamed(last)
|
||||
})
|
||||
|
||||
function addContact(): void {
|
||||
if (canAddContact.value) {
|
||||
contacts.value.push(emptyProviderContact())
|
||||
}
|
||||
}
|
||||
|
||||
function removeContact(index: number): void {
|
||||
contacts.value.splice(index, 1)
|
||||
contactErrors.value.splice(index, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Contact : POST des nouveaux contacts sur
|
||||
* /providers/{id}/contacts, PATCH des existants sur /provider_contacts/{id}
|
||||
* (sous-ressource, groupe provider:write:contacts). RG-3.12 : au moins un bloc
|
||||
* valide. Si l'onglet ne contient QUE des amorces vides, on les soumet pour
|
||||
* declencher la 422 RG-3.04 inline (sur `firstName`) plutot que de finaliser un
|
||||
* onglet vide. Retourne true si l'onglet a ete valide (avance/termine).
|
||||
*/
|
||||
async function submitContacts(onError: (error: unknown) => void): Promise<boolean> {
|
||||
if (providerId.value === null || tabSubmitting.value) {
|
||||
return false
|
||||
}
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
const hasSubmittable = contacts.value.some(c => c.id !== null || !isProviderContactBlank(c))
|
||||
const hasError = await submitRows(
|
||||
contacts.value,
|
||||
contactErrors,
|
||||
async (contact) => {
|
||||
const body = buildProviderContactPayload(contact)
|
||||
if (contact.id === null) {
|
||||
const created = await api.post<ProviderContactResponse>(
|
||||
`/providers/${providerId.value}/contacts`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
contact.id = created.id
|
||||
contact.iri = created['@id'] ?? null
|
||||
}
|
||||
else {
|
||||
await api.patch(`/provider_contacts/${contact.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
onError,
|
||||
contact => hasSubmittable && contact.id === null && isProviderContactBlank(contact),
|
||||
)
|
||||
if (hasError) {
|
||||
return false
|
||||
}
|
||||
completeTab('contact')
|
||||
return true
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Onglet Adresse (ERP-143) ──────────────────────────────────────────────
|
||||
const addresses = ref<ProviderAddressFormDraft[]>([emptyProviderAddress()])
|
||||
// Erreurs 422 par ligne (alignees sur l'index du v-for).
|
||||
const addressErrors = ref<Record<string, string>[]>([])
|
||||
|
||||
// « + Nouvelle adresse » desactive tant que la derniere adresse n'a pas
|
||||
// au moins un site ET une categorie (RG-3.05 / RG-3.09).
|
||||
const canAddAddress = computed(() => {
|
||||
const last = addresses.value[addresses.value.length - 1]
|
||||
return last !== undefined && isProviderAddressValid(last)
|
||||
})
|
||||
|
||||
function addAddress(): void {
|
||||
if (canAddAddress.value) {
|
||||
addresses.value.push(emptyProviderAddress())
|
||||
}
|
||||
}
|
||||
|
||||
function removeAddress(index: number): void {
|
||||
addresses.value.splice(index, 1)
|
||||
addressErrors.value.splice(index, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Adresse : POST des nouvelles adresses sur
|
||||
* /providers/{id}/addresses, PATCH des existantes sur /provider_addresses/{id}
|
||||
* (sous-ressource, groupe provider:write:addresses). Erreurs 422 collectees par
|
||||
* ligne. Retourne true si l'onglet a ete valide (avance/termine).
|
||||
*/
|
||||
async function submitAddresses(onError: (error: unknown) => void): Promise<boolean> {
|
||||
if (providerId.value === null || tabSubmitting.value) {
|
||||
return false
|
||||
}
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
const hasError = await submitRows(
|
||||
addresses.value,
|
||||
addressErrors,
|
||||
async (address) => {
|
||||
const body = buildProviderAddressPayload(address)
|
||||
if (address.id === null) {
|
||||
const created = await api.post<ProviderAddressResponse>(
|
||||
`/providers/${providerId.value}/addresses`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
address.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/provider_addresses/${address.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
onError,
|
||||
)
|
||||
if (hasError) {
|
||||
return false
|
||||
}
|
||||
completeTab('address')
|
||||
return true
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Onglet Comptabilite (ERP-144) ─────────────────────────────────────────
|
||||
const accounting = reactive<ProviderAccountingDraft>(emptyProviderAccounting())
|
||||
const ribs = ref<ProviderRibFormDraft[]>([])
|
||||
const accountingErrors = useFormErrors()
|
||||
// Erreurs 422 par ligne de RIB (alignees sur l'index du v-for).
|
||||
const ribErrors = ref<Record<string, string>[]>([])
|
||||
|
||||
// L'onglet est editable seulement avec accounting.manage (sinon lecture seule).
|
||||
const accountingReadonly = computed(() => isValidated('accounting') || !canAccountingManage.value)
|
||||
|
||||
/**
|
||||
* Met a jour le type de reglement (IRI) en propageant ses RG inter-champs :
|
||||
* - hors VIREMENT (RG-3.07) : on vide la banque (sans objet) ;
|
||||
* - LCR (RG-3.08) : on garantit au moins un bloc RIB visible ; hors LCR, on
|
||||
* purge les erreurs de RIB (les blocs sont conserves mais non persistes).
|
||||
* `isBankRequired` / `isRibRequired` sont calcules par l'appelant (page) a
|
||||
* partir du code resolu via les referentiels.
|
||||
*/
|
||||
function setPaymentType(iri: string | null, isBankRequired: boolean, isRibRequired: boolean): void {
|
||||
accounting.paymentTypeIri = iri
|
||||
if (!isBankRequired) {
|
||||
accounting.bankIri = null
|
||||
}
|
||||
if (isRibRequired) {
|
||||
if (ribs.value.length === 0) {
|
||||
ribs.value.push(emptyProviderRib())
|
||||
}
|
||||
}
|
||||
else {
|
||||
ribErrors.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet (RG-3.08).
|
||||
const canAddRib = computed(() => {
|
||||
const last = ribs.value[ribs.value.length - 1]
|
||||
return last !== undefined && isRibComplete(last)
|
||||
})
|
||||
|
||||
function addRib(): void {
|
||||
if (canAddRib.value) {
|
||||
ribs.value.push(emptyProviderRib())
|
||||
}
|
||||
}
|
||||
|
||||
function removeRib(index: number): void {
|
||||
ribs.value.splice(index, 1)
|
||||
ribErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc RIB visible (sous LCR).
|
||||
if (ribs.value.length === 0) {
|
||||
ribs.value.push(emptyProviderRib())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Comptabilite : (1) sous LCR, POST/PATCH des RIB d'abord
|
||||
* (le back valide RG-3.08 sur le PATCH scalaires, les RIB doivent donc exister
|
||||
* AVANT) ; (2) PATCH des scalaires comptables (groupe provider:write:accounting,
|
||||
* banque envoyee seulement si VIREMENT — RG-3.07). Erreurs RIB par ligne ;
|
||||
* erreurs scalaires inline (bank/paymentType). Retourne true si l'onglet a ete
|
||||
* valide.
|
||||
*/
|
||||
async function submitAccounting(
|
||||
isBankRequired: boolean,
|
||||
isRibRequired: boolean,
|
||||
onRibError: (error: unknown) => void,
|
||||
): Promise<boolean> {
|
||||
if (providerId.value === null || tabSubmitting.value) {
|
||||
return false
|
||||
}
|
||||
tabSubmitting.value = true
|
||||
accountingErrors.clearErrors()
|
||||
try {
|
||||
// 1) RIB d'abord, uniquement sous LCR. Une amorce vide neuve est sautee
|
||||
// s'il reste un autre RIB soumettable ; sinon (LCR sans aucun RIB rempli)
|
||||
// on la soumet pour declencher la 422 NotBlank inline.
|
||||
if (isRibRequired) {
|
||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||
const ribHasError = await submitRows(
|
||||
ribs.value,
|
||||
ribErrors,
|
||||
async (rib) => {
|
||||
const body = buildProviderRibPayload(rib)
|
||||
if (rib.id === null) {
|
||||
const created = await api.post<ProviderRibResponse>(
|
||||
`/providers/${providerId.value}/ribs`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
rib.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/provider_ribs/${rib.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
onRibError,
|
||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||
)
|
||||
if (ribHasError) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||
try {
|
||||
await api.patch(
|
||||
`/providers/${providerId.value}`,
|
||||
buildProviderAccountingPayload(accounting, isBankRequired),
|
||||
{ toast: false },
|
||||
)
|
||||
}
|
||||
catch (error) {
|
||||
accountingErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
|
||||
return false
|
||||
}
|
||||
|
||||
completeTab('accounting')
|
||||
return true
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// etat
|
||||
main,
|
||||
providerId,
|
||||
mainLocked,
|
||||
mainSubmitting,
|
||||
tabSubmitting,
|
||||
mainErrors,
|
||||
// onglets
|
||||
canAccountingView,
|
||||
canAccountingManage,
|
||||
tabKeys,
|
||||
activeTab,
|
||||
unlockedIndex,
|
||||
validated,
|
||||
editMode,
|
||||
isValidated,
|
||||
// contacts
|
||||
contacts,
|
||||
contactErrors,
|
||||
canAddContact,
|
||||
addContact,
|
||||
removeContact,
|
||||
submitContacts,
|
||||
// adresses
|
||||
addresses,
|
||||
addressErrors,
|
||||
canAddAddress,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
submitAddresses,
|
||||
// comptabilite
|
||||
accounting,
|
||||
ribs,
|
||||
accountingErrors,
|
||||
ribErrors,
|
||||
accountingReadonly,
|
||||
setPaymentType,
|
||||
canAddRib,
|
||||
addRib,
|
||||
removeRib,
|
||||
submitAccounting,
|
||||
// actions
|
||||
validateMainFront,
|
||||
buildMainPayload,
|
||||
submitMain,
|
||||
updateMain,
|
||||
patchProvider,
|
||||
completeTab,
|
||||
submitRows,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Charge les referentiels (listes courtes) alimentant les selects du formulaire
|
||||
* principal de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) :
|
||||
* categories (type PRESTATAIRE) et sites (86 / 17 / 82).
|
||||
*
|
||||
* Miroir reduit de `useSupplierReferentials` (M2) : a ce stade (formulaire
|
||||
* principal) seuls categories + sites sont necessaires. Les referentiels
|
||||
* comptables (modes de TVA, delais/types de reglement, banques) seront charges
|
||||
* par l'onglet Comptabilite (ERP-144).
|
||||
*
|
||||
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
|
||||
* `?pagination=false` (referentiels de quelques entrees), avec l'en-tete
|
||||
* `Accept: application/ld+json` impose par API Platform 4 pour obtenir l'enveloppe
|
||||
* Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`), renvoyee telle
|
||||
* quelle dans le payload POST (relations M2M).
|
||||
*
|
||||
* Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole ; un
|
||||
* echec (permission manquante, reseau) laisse simplement la liste vide.
|
||||
*
|
||||
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
|
||||
*/
|
||||
|
||||
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
|
||||
export interface RefOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Option de type de reglement enrichie de son code stable (RG-3.07 / RG-3.08). */
|
||||
export interface PaymentTypeOption extends RefOption {
|
||||
code: string
|
||||
}
|
||||
|
||||
interface HydraMember {
|
||||
'@id': string
|
||||
}
|
||||
|
||||
interface ReferentialMember extends HydraMember {
|
||||
code: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface CategoryMember extends HydraMember {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface SiteMember extends HydraMember {
|
||||
name: string
|
||||
postalCode: string
|
||||
}
|
||||
|
||||
interface CountryMember extends HydraMember {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||
|
||||
export function useProviderReferentials() {
|
||||
const api = useApi()
|
||||
|
||||
const categories = ref<RefOption[]>([])
|
||||
const sites = ref<RefOption[]>([])
|
||||
const countries = ref<RefOption[]>([])
|
||||
// Referentiels comptables (charges a la demande via loadAccounting).
|
||||
const tvaModes = ref<RefOption[]>([])
|
||||
const paymentDelays = ref<RefOption[]>([])
|
||||
const paymentTypes = ref<PaymentTypeOption[]>([])
|
||||
const banks = ref<RefOption[]>([])
|
||||
|
||||
/** Recupere une collection complete (pagination desactivee) en Hydra. */
|
||||
async function fetchAll<T extends HydraMember>(
|
||||
url: string,
|
||||
query: Record<string, string | string[]> = {},
|
||||
): Promise<T[]> {
|
||||
const res = await api.get<{ member?: T[] }>(
|
||||
url,
|
||||
{ pagination: 'false', ...query },
|
||||
{ headers: LD_JSON_HEADERS, toast: false },
|
||||
)
|
||||
return res.member ?? []
|
||||
}
|
||||
|
||||
/** Charge en parallele les referentiels du formulaire principal (categories + sites). */
|
||||
async function loadMain(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
// RG-3.09 : un prestataire ne porte que des categories de type
|
||||
// PRESTATAIRE -> filtre cote API. Libelle affiche = `name`.
|
||||
fetchAll<CategoryMember>('/categories', { typeCode: 'PRESTATAIRE' })
|
||||
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name })) }),
|
||||
// Sites (RG-3.03) : libelle = numero de departement (2 premiers chiffres
|
||||
// du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ».
|
||||
fetchAll<SiteMember>('/sites')
|
||||
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
|
||||
// Pays (ERP-116) : la valeur d'option est le NOM du pays (l'adresse stocke
|
||||
// `country` en chaine libre, « France »...). value === label. Aligne sur
|
||||
// les ecrans client/fournisseur. Sert le select Pays de l'onglet Adresse.
|
||||
fetchAll<CountryMember>('/countries')
|
||||
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge les referentiels comptables (onglet Comptabilite, ERP-144). Appele
|
||||
* uniquement quand l'utilisateur peut voir l'onglet (accounting.view). Resilient
|
||||
* (allSettled) : un referentiel en echec reste vide.
|
||||
*/
|
||||
async function loadAccounting(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
fetchAll<ReferentialMember>('/tva_modes')
|
||||
.then((list) => { tvaModes.value = list.map(t => ({ value: t['@id'], label: t.label })) }),
|
||||
fetchAll<ReferentialMember>('/payment_delays')
|
||||
.then((list) => { paymentDelays.value = list.map(d => ({ value: d['@id'], label: d.label })) }),
|
||||
// Le code stable du type sert les RG-3.07 (VIREMENT) / RG-3.08 (LCR).
|
||||
fetchAll<ReferentialMember>('/payment_types')
|
||||
.then((list) => { paymentTypes.value = list.map(p => ({ value: p['@id'], label: p.label, code: p.code })) }),
|
||||
fetchAll<ReferentialMember>('/banks')
|
||||
.then((list) => { banks.value = list.map(b => ({ value: b['@id'], label: b.label })) }),
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
sites,
|
||||
countries,
|
||||
tvaModes,
|
||||
paymentDelays,
|
||||
paymentTypes,
|
||||
banks,
|
||||
loadMain,
|
||||
loadAccounting,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||
|
||||
/**
|
||||
* Site Starseed rattache DIRECTEMENT au prestataire (M2M `provider_site`,
|
||||
* RG-3.03), tel qu'embarque en LISTE (groupe site:read) pour la colonne « Site »
|
||||
* du Repertoire (badges colores).
|
||||
*
|
||||
* Difference M3 vs M2 : au M2 les sites venaient de l'agregat dedoublonne des
|
||||
* adresses (`Supplier::getSites()`) ; ici c'est une relation directe portee par
|
||||
* le formulaire principal (cf. spec-back M3 § 2.12).
|
||||
*/
|
||||
export interface ProviderSite {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorie (type PRESTATAIRE) rattachee au prestataire, embarquee en LISTE
|
||||
* (groupe category:read). La colonne « Catégories » affiche le `name` (cohérence
|
||||
* M1/M2 — libellé = `name`, pas `code`).
|
||||
*/
|
||||
export interface ProviderCategory {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue MINIMALE d'un prestataire pour le Repertoire (datatable). Volontairement
|
||||
* partielle : seuls les champs des colonnes + l'id (navigation) sont types ici.
|
||||
* Le detail complet (onglets) est hors perimetre de cet ecran (ERP-140).
|
||||
*/
|
||||
export interface Provider {
|
||||
id: number
|
||||
companyName: string
|
||||
categories: ProviderCategory[]
|
||||
sites: ProviderSite[]
|
||||
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
|
||||
updatedAt: string | null
|
||||
isArchived: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Repertoire prestataires (ERP-140) — simple enveloppe de `usePaginatedList<Provider>`
|
||||
* sur la ressource `/providers` (pagination serveur obligatoire ; jamais de
|
||||
* chargement integral en memoire). Miroir de `useSuppliersRepository` (M2).
|
||||
*
|
||||
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
|
||||
* par la page via `setFilters` du composable partage — la remise en page 1 est
|
||||
* garantie. Par defaut, aucun `includeArchived` n'est envoye : le back masque
|
||||
* donc les prestataires archives (exclusion par defaut, spec-back § 2.11).
|
||||
*
|
||||
* Le cloisonnement par site est applique AUTOMATIQUEMENT cote back (§ 2.13) en
|
||||
* fonction de l'utilisateur — rien a filtrer cote front.
|
||||
*
|
||||
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
|
||||
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
|
||||
* `usePaginatedList`. Aucun reset au logout a gerer.
|
||||
*/
|
||||
export function useProvidersRepository() {
|
||||
return usePaginatedList<Provider>({ url: '/providers' })
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete : retour consultation + nom du prestataire. -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
v-bind="{ ariaLabel: t('technique.providers.edit.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Etats de chargement / introuvable. -->
|
||||
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('technique.providers.edit.loading') }}</p>
|
||||
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('technique.providers.edit.notFound') }}</p>
|
||||
|
||||
<template v-else-if="provider">
|
||||
<!-- ── Bloc principal (pre-rempli, editable si `manage`) ──────────── -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="main.companyName"
|
||||
:label="t('technique.providers.form.main.companyName')"
|
||||
:required="true"
|
||||
:readonly="businessReadonly"
|
||||
:error="mainErrors.errors.companyName"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="main.categoryIris"
|
||||
:options="referentials.categories.value"
|
||||
:label="t('technique.providers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="businessReadonly"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.categories"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="main.siteIris"
|
||||
:options="referentials.sites.value"
|
||||
:label="t('technique.providers.form.main.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="businessReadonly"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.sites"
|
||||
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.edit.save')"
|
||||
:disabled="mainSubmitting"
|
||||
@click="onUpdateMain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets : navigation LIBRE, edition independante par onglet ──── -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||
<!-- Onglet Contact -->
|
||||
<template #contact>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ProviderContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:removable="index > 0"
|
||||
:readonly="businessReadonly"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
/>
|
||||
<div v-if="!businessReadonly" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.form.contact.add')"
|
||||
:disabled="!canAddContact"
|
||||
@click="addContact"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.edit.save')"
|
||||
:disabled="tabSubmitting"
|
||||
@click="onSubmitContacts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Adresse -->
|
||||
<template #address>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ProviderAddressBlock
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:category-options="referentials.categories.value"
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="index > 0"
|
||||
:readonly="businessReadonly"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@degraded="onAddressDegraded"
|
||||
/>
|
||||
<div v-if="!businessReadonly" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.form.address.add')"
|
||||
:disabled="!canAddAddress"
|
||||
@click="addAddress"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.edit.save')"
|
||||
:disabled="tabSubmitting"
|
||||
@click="onSubmitAddresses"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Comptabilite (present si accounting.view ; editable si manage). -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="accounting.siren"
|
||||
:label="t('technique.providers.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.siren"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:label="t('technique.providers.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.accountNumber"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="referentials.tvaModes.value"
|
||||
:label="t('technique.providers.form.accounting.tvaMode')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.tvaMode"
|
||||
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:label="t('technique.providers.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.nTva"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="referentials.paymentDelays.value"
|
||||
:label="t('technique.providers.form.accounting.paymentDelay')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentDelay"
|
||||
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="referentials.paymentTypes.value"
|
||||
:label="t('technique.providers.form.accounting.paymentType')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentType"
|
||||
@update:model-value="onPaymentTypeChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="isBankRequired"
|
||||
:model-value="accounting.bankIri"
|
||||
:options="referentials.banks.value"
|
||||
:label="t('technique.providers.form.accounting.bank')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.bank"
|
||||
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). -->
|
||||
<div
|
||||
v-for="(rib, index) in visibleRibs"
|
||||
:key="index"
|
||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('technique.providers.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.label"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:label="t('technique.providers.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.bic"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:label="t('technique.providers.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.iban"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
v-if="isRibRequired"
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.form.accounting.addRib')"
|
||||
:disabled="!canAddRib"
|
||||
@click="addRib"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.edit.save')"
|
||||
:disabled="tabSubmitting"
|
||||
@click="onSubmitAccounting"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MalioTabList>
|
||||
</template>
|
||||
|
||||
<!-- Modal de confirmation generique (suppression contact / adresse / RIB). -->
|
||||
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2>
|
||||
</template>
|
||||
<p>{{ confirmModal.message }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('technique.providers.form.confirmDelete.cancel')"
|
||||
@click="confirmModal.open = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="flex-1"
|
||||
:label="t('technique.providers.form.confirmDelete.confirm')"
|
||||
@click="runConfirm"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useProvider } from '~/modules/technique/composables/useProvider'
|
||||
import { useProviderReferentials, type RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
|
||||
import {
|
||||
canEditProvider,
|
||||
irisOf,
|
||||
mapAccountingDraft,
|
||||
mapAddressToDraft,
|
||||
mapContactToDraft,
|
||||
mapRibToDraft,
|
||||
paymentTypeCodeOf,
|
||||
} from '~/modules/technique/utils/forms/providerDetail'
|
||||
import {
|
||||
isBankRequiredForPaymentType,
|
||||
isRibRequiredForPaymentType,
|
||||
} from '~/modules/technique/utils/forms/providerAccounting'
|
||||
import {
|
||||
emptyProviderAddress,
|
||||
emptyProviderContact,
|
||||
emptyProviderRib,
|
||||
} from '~/modules/technique/types/providerForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
|
||||
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can, canAny } = usePermissions()
|
||||
|
||||
const providerId = route.params.id as string
|
||||
|
||||
// Acces : l'edition exige `manage` OU `accounting.manage` (le role Compta edite
|
||||
// son onglet). Sinon retour consultation.
|
||||
if (!canEditProvider(canAny)) {
|
||||
await navigateTo(`/providers/${providerId}`)
|
||||
}
|
||||
|
||||
const businessReadonly = computed(() => !can('technique.providers.manage'))
|
||||
|
||||
const referentials = useProviderReferentials()
|
||||
const { provider, loading, error, load } = useProvider(providerId)
|
||||
|
||||
const {
|
||||
main,
|
||||
providerId: formProviderId,
|
||||
mainErrors,
|
||||
mainSubmitting,
|
||||
tabSubmitting,
|
||||
editMode,
|
||||
canAccountingView,
|
||||
tabKeys,
|
||||
activeTab,
|
||||
contacts,
|
||||
contactErrors,
|
||||
canAddContact,
|
||||
addContact,
|
||||
removeContact,
|
||||
submitContacts,
|
||||
addresses,
|
||||
addressErrors,
|
||||
canAddAddress,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
submitAddresses,
|
||||
accounting,
|
||||
ribs,
|
||||
accountingErrors,
|
||||
ribErrors,
|
||||
accountingReadonly,
|
||||
setPaymentType,
|
||||
canAddRib,
|
||||
addRib,
|
||||
removeRib,
|
||||
submitAccounting,
|
||||
updateMain,
|
||||
} = useProviderForm()
|
||||
|
||||
// Modification : navigation libre + pas de verrouillage a la validation.
|
||||
editMode.value = true
|
||||
activeTab.value = 'contact'
|
||||
|
||||
const headerTitle = computed(() => provider.value?.companyName || t('technique.providers.edit.title'))
|
||||
useHead({ title: t('technique.providers.edit.title') })
|
||||
|
||||
// ── Onglets (navigation libre ; Comptabilite si accounting.view) ───────────────
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
contact: 'mdi:account-box-plus-outline',
|
||||
address: 'mdi:map-marker-outline',
|
||||
accounting: 'mdi:bank-circle-outline',
|
||||
}
|
||||
const tabs = computed(() => tabKeys.value.map(key => ({
|
||||
key,
|
||||
label: t(`technique.providers.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
})))
|
||||
|
||||
/** Pre-remplit les brouillons depuis la SEULE reponse detail. */
|
||||
function prefill(): void {
|
||||
const d = provider.value
|
||||
if (!d) return
|
||||
|
||||
// Indispensable : pilote les URLs des PATCH/POST par onglet (sinon les submits no-op).
|
||||
formProviderId.value = d.id
|
||||
|
||||
main.companyName = d.companyName ?? null
|
||||
main.categoryIris = irisOf(d.categories)
|
||||
main.siteIris = irisOf(d.sites)
|
||||
|
||||
const mappedContacts = (d.contacts ?? []).map(mapContactToDraft)
|
||||
contacts.value = mappedContacts.length > 0 ? mappedContacts : [emptyProviderContact()]
|
||||
|
||||
const mappedAddresses = (d.addresses ?? []).map(mapAddressToDraft)
|
||||
addresses.value = mappedAddresses.length > 0 ? mappedAddresses : [emptyProviderAddress()]
|
||||
|
||||
if (canAccountingView.value) {
|
||||
Object.assign(accounting, mapAccountingDraft(d))
|
||||
ribs.value = (d.ribs ?? []).map(mapRibToDraft)
|
||||
// Garantit un bloc RIB visible si le type de reglement est LCR.
|
||||
if (isRibRequiredForPaymentType(paymentTypeCodeOf(d.paymentType)) && ribs.value.length === 0) {
|
||||
ribs.value.push(emptyProviderRib())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Comptabilite : RG-3.07 / RG-3.08 pilotees par le code du type de reglement ──
|
||||
const selectedPaymentTypeCode = computed(() =>
|
||||
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
|
||||
)
|
||||
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
|
||||
|
||||
function onPaymentTypeChange(value: string | number | null): void {
|
||||
const iri = value === null ? null : String(value)
|
||||
const code = referentials.paymentTypes.value.find(p => p.value === iri)?.code ?? null
|
||||
setPaymentType(iri, isBankRequiredForPaymentType(code), isRibRequiredForPaymentType(code))
|
||||
}
|
||||
|
||||
// ── Options adresses ──────────────────────────────────────────────────────────
|
||||
const contactOptions = computed<RefOption[]>(() =>
|
||||
contacts.value
|
||||
.filter(c => c.iri !== null)
|
||||
.map(c => ({
|
||||
value: c.iri as string,
|
||||
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
|
||||
})),
|
||||
)
|
||||
|
||||
const countryOptions = computed<RefOption[]>(() => {
|
||||
const list = referentials.countries.value
|
||||
return list.some(c => c.value === 'France')
|
||||
? list
|
||||
: [{ value: 'France', label: 'France' }, ...list]
|
||||
})
|
||||
|
||||
const addressDegradedNotified = ref(false)
|
||||
function onAddressDegraded(): void {
|
||||
if (addressDegradedNotified.value) return
|
||||
addressDegradedNotified.value = true
|
||||
toast.warning({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: t('technique.providers.form.address.degraded'),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Navigation + helpers ──────────────────────────────────────────────────────
|
||||
function goBack(): void {
|
||||
router.push(`/providers/${providerId}`)
|
||||
}
|
||||
|
||||
function apiErrorMessage(err: unknown): string {
|
||||
const data = (err as { response?: { _data?: unknown } })?.response?._data
|
||||
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
|
||||
}
|
||||
|
||||
/** PATCH du bloc principal (groupe provider:write:main). */
|
||||
async function onUpdateMain(): Promise<void> {
|
||||
if (await updateMain()) {
|
||||
toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmitContacts(): Promise<void> {
|
||||
const ok = await submitContacts(err => toast.error({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: apiErrorMessage(err),
|
||||
}))
|
||||
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||
}
|
||||
|
||||
async function onSubmitAddresses(): Promise<void> {
|
||||
const ok = await submitAddresses(err => toast.error({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: apiErrorMessage(err),
|
||||
}))
|
||||
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||
}
|
||||
|
||||
async function onSubmitAccounting(): Promise<void> {
|
||||
const ok = await submitAccounting(
|
||||
isBankRequired.value,
|
||||
isRibRequired.value,
|
||||
err => toast.error({ title: t('technique.providers.toast.error'), message: apiErrorMessage(err) }),
|
||||
)
|
||||
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||
}
|
||||
|
||||
// ── Modal de confirmation generique ───────────────────────────────────────────
|
||||
const confirmModal = reactive({
|
||||
open: false,
|
||||
message: '',
|
||||
action: null as null | (() => void),
|
||||
})
|
||||
|
||||
function askConfirm(message: string, action: () => void): void {
|
||||
confirmModal.message = message
|
||||
confirmModal.action = action
|
||||
confirmModal.open = true
|
||||
}
|
||||
|
||||
function runConfirm(): void {
|
||||
confirmModal.action?.()
|
||||
confirmModal.action = null
|
||||
confirmModal.open = false
|
||||
}
|
||||
|
||||
function askRemoveContact(index: number): void {
|
||||
askConfirm(t('technique.providers.form.confirmDelete.contact'), () => removeContact(index))
|
||||
}
|
||||
|
||||
function askRemoveAddress(index: number): void {
|
||||
askConfirm(t('technique.providers.form.confirmDelete.address'), () => removeAddress(index))
|
||||
}
|
||||
|
||||
function askRemoveRib(index: number): void {
|
||||
askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
referentials.loadMain().catch(() => {})
|
||||
if (canAccountingView.value) {
|
||||
referentials.loadAccounting().catch(() => {})
|
||||
}
|
||||
await load()
|
||||
prefill()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete : retour repertoire + nom du prestataire + actions. -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
v-bind="{ ariaLabel: t('technique.providers.consultation.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
|
||||
|
||||
<div class="ml-auto flex items-center gap-12">
|
||||
<MalioButton
|
||||
v-if="canEdit"
|
||||
variant="secondary"
|
||||
icon-name="mdi:pencil-outline"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.action.edit')"
|
||||
@click="goEdit"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showArchive"
|
||||
variant="secondary"
|
||||
icon-name="mdi:archive-arrow-down-outline"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.action.archive')"
|
||||
@click="askToggleArchive"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showRestore"
|
||||
variant="secondary"
|
||||
icon-name="mdi:archive-arrow-up-outline"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.action.restore')"
|
||||
@click="askToggleArchive"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Etats de chargement / introuvable. -->
|
||||
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('technique.providers.consultation.loading') }}</p>
|
||||
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('technique.providers.consultation.notFound') }}</p>
|
||||
|
||||
<template v-else-if="provider">
|
||||
<!-- ── Bloc principal (lecture seule) ─────────────────────────────── -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
:model-value="provider.companyName"
|
||||
:label="t('technique.providers.form.main.companyName')"
|
||||
readonly
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="mainCategoryIris"
|
||||
:options="mainCategoryOptions"
|
||||
:label="t('technique.providers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
readonly
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="mainSiteIris"
|
||||
:options="mainSiteOptions"
|
||||
:label="t('technique.providers.form.main.sites')"
|
||||
:display-tag="true"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets (navigation libre, tout en lecture seule) ──────────── -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||
<!-- Onglet Contacts -->
|
||||
<template #contacts>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ProviderContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Adresse -->
|
||||
<template #address>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ProviderAddressBlock
|
||||
v-for="(view, index) in addressViews"
|
||||
:key="index"
|
||||
:model-value="view.draft"
|
||||
:category-options="view.categoryOptions"
|
||||
:site-options="view.siteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptionsFor(view.draft.country)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglets placeholder « A venir » (comme les autres modules). -->
|
||||
<template #reports><ComingSoonPlaceholder /></template>
|
||||
<template #exchanges><ComingSoonPlaceholder /></template>
|
||||
|
||||
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" readonly />
|
||||
<MalioInputText :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" readonly />
|
||||
<MalioSelect :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" readonly empty-option-label="" />
|
||||
<MalioInputText :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" readonly />
|
||||
<MalioSelect :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" readonly empty-option-label="" />
|
||||
<MalioSelect :model-value="accounting.paymentTypeIri" :options="paymentTypeOptions" :label="t('technique.providers.form.accounting.paymentType')" readonly empty-option-label="" />
|
||||
<MalioSelect v-if="isBankRequired" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" readonly empty-option-label="" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB (uniquement si type de reglement = LCR). -->
|
||||
<div
|
||||
v-for="(rib, index) in visibleRibs"
|
||||
:key="index"
|
||||
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" readonly />
|
||||
<MalioInputText :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" readonly />
|
||||
<MalioInputText :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" readonly />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MalioTabList>
|
||||
</template>
|
||||
|
||||
<!-- Modal de confirmation archivage / restauration. -->
|
||||
<MalioModal v-model="confirmArchive.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ confirmArchive.title }}</h2>
|
||||
</template>
|
||||
<p>{{ confirmArchive.message }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('technique.providers.form.confirmDelete.cancel')"
|
||||
@click="confirmArchive.open = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="flex-1"
|
||||
:label="confirmArchive.confirmLabel"
|
||||
@click="runToggleArchive"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useProvider } from '~/modules/technique/composables/useProvider'
|
||||
import {
|
||||
canEditProvider,
|
||||
categoryOptionsOf,
|
||||
contactOptionsOf,
|
||||
irisOf,
|
||||
mapAccountingDraft,
|
||||
mapAddressToDraft,
|
||||
mapContactToDraft,
|
||||
mapRibToDraft,
|
||||
paymentTypeCodeOf,
|
||||
referentialOptionOf,
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
siteOptionsOf,
|
||||
} from '~/modules/technique/utils/forms/providerDetail'
|
||||
import { isBankRequiredForPaymentType, isRibRequiredForPaymentType } from '~/modules/technique/utils/forms/providerAccounting'
|
||||
import { emptyProviderAddress, emptyProviderContact } from '~/modules/technique/types/providerForm'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can, canAny } = usePermissions()
|
||||
|
||||
const providerId = route.params.id as string
|
||||
const { provider, loading, error, load, archive, restore } = useProvider(providerId)
|
||||
|
||||
const canAccountingView = computed(() => can('technique.providers.accounting.view'))
|
||||
const canEdit = computed(() => canEditProvider(canAny))
|
||||
const isArchived = computed(() => provider.value?.isArchived ?? false)
|
||||
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
|
||||
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
|
||||
|
||||
const headerTitle = computed(() => provider.value?.companyName || t('technique.providers.consultation.title'))
|
||||
useHead({ title: t('technique.providers.consultation.title') })
|
||||
|
||||
// ── Onglets (ordre spec : Contacts · Adresse · Rapports · Échanges · Comptabilité) ──
|
||||
const activeTab = ref('contacts')
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
contacts: 'mdi:account-box-plus-outline',
|
||||
address: 'mdi:map-marker-outline',
|
||||
reports: 'mdi:file-chart-outline',
|
||||
exchanges: 'mdi:swap-horizontal',
|
||||
accounting: 'mdi:bank-circle-outline',
|
||||
}
|
||||
const tabs = computed(() => {
|
||||
const keys = ['contacts', 'address', 'reports', 'exchanges']
|
||||
if (canAccountingView.value) keys.push('accounting')
|
||||
return keys.map(key => ({ key, label: t(`technique.providers.tab.${key}`), icon: TAB_ICONS[key] }))
|
||||
})
|
||||
|
||||
// ── Donnees mappees depuis la SEULE reponse detail ─────────────────────────────
|
||||
const mainCategoryIris = computed(() => irisOf(provider.value?.categories))
|
||||
const mainSiteIris = computed(() => irisOf(provider.value?.sites))
|
||||
const mainCategoryOptions = computed(() => categoryOptionsOf(provider.value?.categories))
|
||||
const mainSiteOptions = computed(() => siteOptionsOf(provider.value?.sites))
|
||||
|
||||
// Au moins un bloc affiche meme sans donnee (bloc vide en lecture seule, comme
|
||||
// l'onglet Comptabilite et les autres modules — pas de message « Aucun … »).
|
||||
const contacts = computed(() => {
|
||||
const list = (provider.value?.contacts ?? []).map(mapContactToDraft)
|
||||
return list.length > 0 ? list : [emptyProviderContact()]
|
||||
})
|
||||
// Contacts rattachables (pour resoudre les libelles des contacts lies aux adresses).
|
||||
const contactOptions = computed(() => contactOptionsOf(provider.value?.contacts))
|
||||
|
||||
// Vue par adresse : brouillon + options propres a l'adresse (sites/categories embarques).
|
||||
const addressViews = computed(() => {
|
||||
const views = (provider.value?.addresses ?? []).map(address => ({
|
||||
draft: mapAddressToDraft(address),
|
||||
siteOptions: siteOptionsOf(address.sites),
|
||||
categoryOptions: categoryOptionsOf(address.categories),
|
||||
}))
|
||||
return views.length > 0
|
||||
? views
|
||||
: [{ draft: emptyProviderAddress(), siteOptions: [], categoryOptions: [] }]
|
||||
})
|
||||
|
||||
/** Pays : une seule option (la valeur courante), suffisant pour l'affichage readonly. */
|
||||
function countryOptionsFor(country: string): { value: string, label: string }[] {
|
||||
return country ? [{ value: country, label: country }] : []
|
||||
}
|
||||
|
||||
// ── Comptabilite (presente uniquement si accounting.view) ──────────────────────
|
||||
const accounting = computed(() => mapAccountingDraft(provider.value ?? { id: 0, '@id': '' }))
|
||||
const paymentTypeCode = computed(() => paymentTypeCodeOf(provider.value?.paymentType))
|
||||
const isBankRequired = computed(() => isBankRequiredForPaymentType(paymentTypeCode.value))
|
||||
const isRibRequired = computed(() => isRibRequiredForPaymentType(paymentTypeCode.value))
|
||||
const visibleRibs = computed(() => isRibRequired.value ? (provider.value?.ribs ?? []).map(mapRibToDraft) : [])
|
||||
|
||||
// Options « une entree » construites depuis l'embed (libelles role-independants).
|
||||
const tvaModeOptions = computed(() => referentialOptionOf(provider.value?.tvaMode))
|
||||
const paymentDelayOptions = computed(() => referentialOptionOf(provider.value?.paymentDelay))
|
||||
const paymentTypeOptions = computed(() => referentialOptionOf(provider.value?.paymentType))
|
||||
const bankOptions = computed(() => referentialOptionOf(provider.value?.bank))
|
||||
|
||||
// ── Navigation / actions ───────────────────────────────────────────────────────
|
||||
function goBack(): void {
|
||||
router.push('/providers')
|
||||
}
|
||||
|
||||
function goEdit(): void {
|
||||
router.push(`/providers/${providerId}/edit`)
|
||||
}
|
||||
|
||||
// ── Archivage / restauration ───────────────────────────────────────────────────
|
||||
const confirmArchive = reactive({
|
||||
open: false,
|
||||
title: '',
|
||||
message: '',
|
||||
confirmLabel: '',
|
||||
})
|
||||
|
||||
function askToggleArchive(): void {
|
||||
const archiving = !isArchived.value
|
||||
confirmArchive.title = archiving
|
||||
? t('technique.providers.action.archive')
|
||||
: t('technique.providers.action.restore')
|
||||
confirmArchive.message = archiving
|
||||
? t('technique.providers.consultation.confirmArchive')
|
||||
: t('technique.providers.consultation.confirmRestore')
|
||||
confirmArchive.confirmLabel = archiving
|
||||
? t('technique.providers.action.archive')
|
||||
: t('technique.providers.action.restore')
|
||||
confirmArchive.open = true
|
||||
}
|
||||
|
||||
async function runToggleArchive(): Promise<void> {
|
||||
const archiving = !isArchived.value
|
||||
confirmArchive.open = false
|
||||
try {
|
||||
await (archiving ? archive() : restore())
|
||||
toast.success({
|
||||
title: archiving
|
||||
? t('technique.providers.toast.archiveSuccess')
|
||||
: t('technique.providers.toast.restoreSuccess'),
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// 409 a la restauration (homonyme actif) ou autre : toast generique.
|
||||
toast.error({ title: t('technique.providers.toast.error') })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('technique.providers.title') }}
|
||||
<template #actions>
|
||||
<!-- gap-8 = 32px d'espacement entre Filtrer et Ajouter. -->
|
||||
<div class="flex items-center gap-8">
|
||||
<!-- Bouton Filtrer a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="tertiary"
|
||||
:label="filterButtonLabel"
|
||||
icon-name="mdi:tune"
|
||||
icon-position="left"
|
||||
icon-size="24"
|
||||
@click="openFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
variant="secondary"
|
||||
:label="t('technique.providers.add')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="goToCreate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Datatable branchee sur usePaginatedList via useProvidersRepository :
|
||||
pagination serveur, tri companyName ASC par defaut (cote back),
|
||||
archives masques par defaut. Cloisonnement par site cote back. -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
row-clickable
|
||||
table-class="table-fixed providers-table"
|
||||
:empty-message="t('technique.providers.empty')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<!-- Categories : libelles (name) separes par une virgule. -->
|
||||
<template #cell-categories="{ item }">
|
||||
{{ formatCategories(item) }}
|
||||
</template>
|
||||
|
||||
<!-- Sites : badges colores (name + color), relation directe du prestataire. -->
|
||||
<template #cell-sites="{ item }">
|
||||
<span class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="site in (item.sites as ProviderSite[])"
|
||||
:key="site.id"
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 font-medium text-white"
|
||||
:style="{ backgroundColor: site.color }"
|
||||
>
|
||||
{{ site.name }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Derniere activite : date de derniere modification (updatedAt), format JJ-MM-AAAA. -->
|
||||
<template #cell-lastActivity="{ item }">
|
||||
{{ formatLastActivity(item) }}
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<div class="flex justify-center mt-4">
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="primary"
|
||||
:label="t('technique.providers.export')"
|
||||
:disabled="exporting"
|
||||
@click="exportXlsx"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
|
||||
« Voir les résultats ». Meme pattern que le repertoire fournisseurs.
|
||||
Etat 100 % local, jamais dans l'URL (regle ABSOLUE n°6). -->
|
||||
<MalioDrawer
|
||||
v-model="filterDrawerOpen"
|
||||
drawer-class="max-w-[450px]"
|
||||
body-class="p-0"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold uppercase">{{ t('technique.providers.filters.title') }}</h2>
|
||||
</template>
|
||||
|
||||
<MalioAccordion>
|
||||
<!-- Recherche : nom entreprise + contact + email (param `search`). -->
|
||||
<MalioAccordionItem :title="t('technique.providers.filters.search')" value="search">
|
||||
<MalioInputText
|
||||
v-model="draftSearch"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Categories (type PRESTATAIRE) : cases a cocher (multi). Valeur = code stable. -->
|
||||
<MalioAccordionItem :title="t('technique.providers.filters.categories')" value="categories">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in categoryOptions"
|
||||
:id="`filter-category-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftCategoryCodes.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleCategory(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Sites : cases a cocher (multi). Valeur = id du site. -->
|
||||
<MalioAccordionItem :title="t('technique.providers.filters.sites')" value="sites">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in siteOptions"
|
||||
:id="`filter-site-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftSiteIds.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleSite(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
|
||||
<MalioAccordionItem :title="t('technique.providers.filters.status')" value="status">
|
||||
<MalioCheckbox
|
||||
id="filter-include-archived"
|
||||
:label="t('technique.providers.filters.includeArchived')"
|
||||
:model-value="draftIncludeArchived"
|
||||
@update:model-value="(val: boolean) => draftIncludeArchived = val"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('technique.providers.filters.reset')"
|
||||
button-class="w-m-btn-action"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.filters.apply')"
|
||||
button-class="w-[170px]"
|
||||
@click="applyFilters"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { Provider, ProviderSite } from '~/modules/technique/composables/useProvidersRepository'
|
||||
|
||||
interface FilterOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('technique.providers.title') })
|
||||
|
||||
// Bouton « Ajouter » reserve a `manage` (POST /providers garde manage seul —
|
||||
// Compta cree pas). « Exporter » et « Filtrer » suivent `view`.
|
||||
const canManage = computed(() => can('technique.providers.manage'))
|
||||
const canView = computed(() => can('technique.providers.view'))
|
||||
|
||||
const {
|
||||
items: providers,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadProviders,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
setFilters,
|
||||
} = useProvidersRepository()
|
||||
|
||||
// Mappe les prestataires en objets « plats » pour MalioDataTable (items typees
|
||||
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
|
||||
// implicite, contrairement a l'interface Provider. Meme pattern que fournisseurs.
|
||||
const rows = computed(() => providers.value.map(provider => ({
|
||||
id: provider.id,
|
||||
companyName: provider.companyName,
|
||||
categories: provider.categories,
|
||||
sites: provider.sites,
|
||||
updatedAt: provider.updatedAt,
|
||||
})))
|
||||
|
||||
const columns = [
|
||||
{ key: 'companyName', label: t('technique.providers.column.companyName') },
|
||||
{ key: 'categories', label: t('technique.providers.column.categories') },
|
||||
{ key: 'sites', label: t('technique.providers.column.sites') },
|
||||
{ key: 'lastActivity', label: t('technique.providers.column.lastActivity') },
|
||||
]
|
||||
|
||||
/** Libelles des categories du prestataire, separes par une virgule (name). */
|
||||
function formatCategories(item: Record<string, unknown>): string {
|
||||
const categories = (item.categories as Provider['categories']) ?? []
|
||||
return categories.map(c => c.name).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Derniere activite : date de derniere modification de la fiche (updatedAt,
|
||||
* expose en liste via default:read). Format court francais JJ-MM-AAAA (tirets,
|
||||
* cf. spec-front M3 § Datatable).
|
||||
*/
|
||||
function formatLastActivity(item: Record<string, unknown>): string {
|
||||
const value = item.updatedAt as string | null | undefined
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Garde-fou date invalide : un updatedAt mal forme donnerait « Invalid Date ».
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = date.getFullYear()
|
||||
return `${day}-${month}-${year}`
|
||||
}
|
||||
|
||||
/** Clic sur une ligne → ecran Consultation (route a plat /providers/{id}). */
|
||||
function onRowClick(item: Record<string, unknown>): void {
|
||||
router.push(`/providers/${item.id}`)
|
||||
}
|
||||
|
||||
function goToCreate(): void {
|
||||
router.push('/providers/new')
|
||||
}
|
||||
|
||||
// ── Filtres (drawer) ────────────────────────────────────────────────────────
|
||||
// Deux niveaux d'etat (pattern repertoire fournisseurs) :
|
||||
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
|
||||
// uniquement au clic « Voir les résultats » / « Réinitialiser ».
|
||||
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
|
||||
const filterDrawerOpen = ref(false)
|
||||
|
||||
const draftSearch = ref('')
|
||||
const draftCategoryCodes = ref<string[]>([])
|
||||
const draftSiteIds = ref<string[]>([])
|
||||
const draftIncludeArchived = ref(false)
|
||||
|
||||
const appliedSearch = ref('')
|
||||
const appliedCategoryCodes = ref<string[]>([])
|
||||
const appliedSiteIds = ref<string[]>([])
|
||||
const appliedIncludeArchived = ref(false)
|
||||
|
||||
// Options des selects multi, chargees une fois (referentiels courts).
|
||||
const categoryOptions = ref<FilterOption[]>([])
|
||||
const siteOptions = ref<FilterOption[]>([])
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
let count = 0
|
||||
if (appliedSearch.value.trim() !== '') count++
|
||||
if (appliedCategoryCodes.value.length > 0) count++
|
||||
if (appliedSiteIds.value.length > 0) count++
|
||||
if (appliedIncludeArchived.value) count++
|
||||
return count
|
||||
})
|
||||
|
||||
const filterButtonLabel = computed(() => {
|
||||
const base = t('technique.providers.filters.title')
|
||||
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
|
||||
})
|
||||
|
||||
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la
|
||||
// reouverture reflete les filtres actifs.
|
||||
function openFilters(): void {
|
||||
draftSearch.value = appliedSearch.value
|
||||
draftCategoryCodes.value = [...appliedCategoryCodes.value]
|
||||
draftSiteIds.value = [...appliedSiteIds.value]
|
||||
draftIncludeArchived.value = appliedIncludeArchived.value
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function toggleCategory(code: string, selected: boolean): void {
|
||||
draftCategoryCodes.value = selected
|
||||
? [...draftCategoryCodes.value, code]
|
||||
: draftCategoryCodes.value.filter(c => c !== code)
|
||||
}
|
||||
|
||||
function toggleSite(id: string, selected: boolean): void {
|
||||
draftSiteIds.value = selected
|
||||
? [...draftSiteIds.value, id]
|
||||
: draftSiteIds.value.filter(s => s !== id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le payload de filtres serveur a partir de l'etat applique. Cles
|
||||
* `categoryCode[]` / `siteId[]` pour que PHP les parse en tableaux (OR cote back).
|
||||
* Les filtres vides sont omis pour une query propre.
|
||||
*/
|
||||
function buildFilterPayload(): Record<string, string | string[] | boolean> {
|
||||
const payload: Record<string, string | string[] | boolean> = {}
|
||||
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
||||
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
|
||||
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
|
||||
if (appliedIncludeArchived.value) payload.includeArchived = true
|
||||
return payload
|
||||
}
|
||||
|
||||
// « Voir les résultats » : recopie brouillon → applied, pousse les filtres
|
||||
// (retombe en page 1 via usePaginatedList) et ferme le drawer.
|
||||
function applyFilters(): void {
|
||||
appliedSearch.value = draftSearch.value.trim()
|
||||
appliedCategoryCodes.value = [...draftCategoryCodes.value]
|
||||
appliedSiteIds.value = [...draftSiteIds.value]
|
||||
appliedIncludeArchived.value = draftIncludeArchived.value
|
||||
|
||||
setFilters(buildFilterPayload(), { replace: true })
|
||||
filterDrawerOpen.value = false
|
||||
}
|
||||
|
||||
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
|
||||
// Le drawer reste ouvert pour montrer le formulaire vide.
|
||||
function resetFilters(): void {
|
||||
draftSearch.value = ''
|
||||
draftCategoryCodes.value = []
|
||||
draftSiteIds.value = []
|
||||
draftIncludeArchived.value = false
|
||||
|
||||
appliedSearch.value = ''
|
||||
appliedCategoryCodes.value = []
|
||||
appliedSiteIds.value = []
|
||||
appliedIncludeArchived.value = false
|
||||
|
||||
setFilters({}, { replace: true })
|
||||
}
|
||||
|
||||
/** Charge les referentiels du drawer (categories PRESTATAIRE + sites) via ?pagination=false. */
|
||||
async function loadFilterOptions(): Promise<void> {
|
||||
const [cats, sites] = await Promise.all([
|
||||
api.get<{ member?: Array<{ code: string, name: string }> }>(
|
||||
'/categories',
|
||||
// Taxonomie multi-types : le filtre du repertoire prestataires ne
|
||||
// propose que les categories de type PRESTATAIRE.
|
||||
{ pagination: 'false', typeCode: 'PRESTATAIRE' },
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
),
|
||||
api.get<{ member?: Array<{ id: number, name: string }> }>(
|
||||
'/sites',
|
||||
{ pagination: 'false' },
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
),
|
||||
])
|
||||
|
||||
categoryOptions.value = (cats.member ?? []).map(c => ({ value: c.code, label: c.name }))
|
||||
siteOptions.value = (sites.member ?? []).map(s => ({ value: String(s.id), label: s.name }))
|
||||
}
|
||||
|
||||
// ── Export XLSX ─────────────────────────────────────────────────────────────
|
||||
// Memes filtres que la vue. La colonne SIREN n'est dans le fichier que si
|
||||
// l'utilisateur a accounting.view (gere cote back).
|
||||
const exporting = ref(false)
|
||||
|
||||
async function exportXlsx(): Promise<void> {
|
||||
if (exporting.value) {
|
||||
return
|
||||
}
|
||||
exporting.value = true
|
||||
try {
|
||||
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
|
||||
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
|
||||
// contenu faute d'overload blob sur le client partage — meme approche que
|
||||
// l'export fournisseurs.
|
||||
const blob = await api.get<Blob>('/providers/export.xlsx', buildFilterPayload(), {
|
||||
responseType: 'blob',
|
||||
toast: false,
|
||||
} as unknown as Parameters<typeof api.get>[2])
|
||||
|
||||
triggerDownload(blob, 'repertoire-prestataires.xlsx')
|
||||
}
|
||||
catch {
|
||||
toast.error({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: t('technique.providers.toast.exportError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Declenche le telechargement d'un blob via un lien temporaire. */
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProviders()
|
||||
// Echec du chargement des referentiels non bloquant : la liste s'affiche,
|
||||
// l'utilisateur perd juste les options de filtre.
|
||||
loadFilterOptions().catch(() => {
|
||||
categoryOptions.value = []
|
||||
siteOptions.value = []
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* Colonne Sites uniquement (3e colonne : companyName, categories, SITES,
|
||||
* lastActivity) : ses badges rendent la cellule trop haute. On reduit le padding
|
||||
* vertical de SON td (16px Malio -> 8px) sans toucher les autres colonnes ni les
|
||||
* couleurs/tailles (qui restent sur les defauts Malio).
|
||||
*/
|
||||
:deep(.providers-table tbody td:nth-child(3)) {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,530 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete : retour vers le repertoire + titre. -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('technique.providers.form.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- ── Formulaire principal (pre-onglets) ─────────────────────────────
|
||||
Sans validation de ce bloc, les onglets restent inaccessibles. Au
|
||||
succes du POST, les champs passent en lecture seule et on bascule
|
||||
automatiquement sur l'onglet Contact (PAS d'onglet Information au M3).
|
||||
Selecteur de site present ici (RG-3.03, relation directe). -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="main.companyName"
|
||||
:label="t('technique.providers.form.main.companyName')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:error="mainErrors.errors.companyName"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="main.categoryIris"
|
||||
:options="referentials.categories.value"
|
||||
:label="t('technique.providers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="mainLocked"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.categories"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="main.siteIris"
|
||||
:options="referentials.sites.value"
|
||||
:label="t('technique.providers.form.main.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="mainLocked"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.sites"
|
||||
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!mainLocked" class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.form.submit')"
|
||||
:disabled="mainSubmitting"
|
||||
@click="submitMain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets a validation incrementale ─────────────────────────────
|
||||
Onglet Contact actif (ERP-142) ; Adresse / Comptabilite arrivent aux
|
||||
tickets ERP-143 / 144 : placeholders « A venir » pour l'instant. -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||
<!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
|
||||
<template #contact>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ProviderContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:removable="index > 0"
|
||||
:readonly="isValidated('contact')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
/>
|
||||
<div v-if="!isValidated('contact')" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.form.contact.add')"
|
||||
:disabled="!canAddContact"
|
||||
@click="addContact"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.form.submit')"
|
||||
:disabled="tabSubmitting || providerId === null"
|
||||
@click="onSubmitContacts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Onglet Adresse : saisie multi-adresses (blocs ajoutables). -->
|
||||
<template #address>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ProviderAddressBlock
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:category-options="referentials.categories.value"
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="index > 0"
|
||||
:readonly="isValidated('address')"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@degraded="onAddressDegraded"
|
||||
/>
|
||||
<div v-if="!isValidated('address')" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.form.address.add')"
|
||||
:disabled="!canAddAddress"
|
||||
@click="addAddress"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.form.submit')"
|
||||
:disabled="tabSubmitting || providerId === null"
|
||||
@click="onSubmitAddresses"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Onglet Comptabilite (present uniquement si accounting.view ; editable si manage). -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="accounting.siren"
|
||||
:label="t('technique.providers.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.siren"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:label="t('technique.providers.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.accountNumber"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="referentials.tvaModes.value"
|
||||
:label="t('technique.providers.form.accounting.tvaMode')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.tvaMode"
|
||||
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:label="t('technique.providers.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.nTva"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="referentials.paymentDelays.value"
|
||||
:label="t('technique.providers.form.accounting.paymentDelay')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentDelay"
|
||||
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="referentials.paymentTypes.value"
|
||||
:label="t('technique.providers.form.accounting.paymentType')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentType"
|
||||
@update:model-value="onPaymentTypeChange"
|
||||
/>
|
||||
<!-- Banque : visible et obligatoire seulement si VIREMENT (RG-3.07). -->
|
||||
<MalioSelect
|
||||
v-if="isBankRequired"
|
||||
:model-value="accounting.bankIri"
|
||||
:options="referentials.banks.value"
|
||||
:label="t('technique.providers.form.accounting.bank')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.bank"
|
||||
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). -->
|
||||
<div
|
||||
v-for="(rib, index) in visibleRibs"
|
||||
:key="index"
|
||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('technique.providers.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.label"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:label="t('technique.providers.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.bic"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:label="t('technique.providers.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.iban"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
v-if="isRibRequired"
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.form.accounting.addRib')"
|
||||
:disabled="!canAddRib"
|
||||
@click="addRib"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.form.submit')"
|
||||
:disabled="tabSubmitting || providerId === null"
|
||||
@click="onSubmitAccounting"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MalioTabList>
|
||||
|
||||
<!-- Modal de confirmation generique (suppression d'un bloc contact). -->
|
||||
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2>
|
||||
</template>
|
||||
<p>{{ confirmModal.message }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('technique.providers.form.confirmDelete.cancel')"
|
||||
@click="confirmModal.open = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="flex-1"
|
||||
:label="t('technique.providers.form.confirmDelete.confirm')"
|
||||
@click="runConfirm"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useProviderReferentials, type RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
|
||||
import {
|
||||
isBankRequiredForPaymentType,
|
||||
isRibRequiredForPaymentType,
|
||||
} from '~/modules/technique/utils/forms/providerAccounting'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
|
||||
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('technique.providers.form.title') })
|
||||
|
||||
// Gating de la route : la creation est reservee a `manage` (POST /providers garde
|
||||
// manage seul — Compta ne cree pas). Compta (accounting seul) et Usine sont
|
||||
// rediriges vers le repertoire.
|
||||
if (!can('technique.providers.manage')) {
|
||||
await navigateTo('/providers')
|
||||
}
|
||||
|
||||
const referentials = useProviderReferentials()
|
||||
|
||||
const {
|
||||
main,
|
||||
providerId,
|
||||
mainLocked,
|
||||
mainSubmitting,
|
||||
mainErrors,
|
||||
canAccountingView,
|
||||
tabKeys,
|
||||
activeTab,
|
||||
unlockedIndex,
|
||||
submitMain,
|
||||
tabSubmitting,
|
||||
isValidated,
|
||||
contacts,
|
||||
contactErrors,
|
||||
canAddContact,
|
||||
addContact,
|
||||
removeContact,
|
||||
submitContacts,
|
||||
addresses,
|
||||
addressErrors,
|
||||
canAddAddress,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
submitAddresses,
|
||||
accounting,
|
||||
ribs,
|
||||
accountingErrors,
|
||||
ribErrors,
|
||||
accountingReadonly,
|
||||
setPaymentType,
|
||||
canAddRib,
|
||||
addRib,
|
||||
removeRib,
|
||||
submitAccounting,
|
||||
} = useProviderForm()
|
||||
|
||||
/** Retour vers le repertoire prestataires (fleche d'en-tete). */
|
||||
function goBack(): void {
|
||||
router.push('/providers')
|
||||
}
|
||||
|
||||
/**
|
||||
* Message d'erreur a afficher dans un toast a partir d'une erreur d'API. Retourne
|
||||
* TOUJOURS une chaine (le composant de toast plante sur `undefined`).
|
||||
*/
|
||||
function apiErrorMessage(error: unknown): string {
|
||||
const data = (error as { response?: { _data?: unknown } })?.response?._data
|
||||
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
|
||||
}
|
||||
|
||||
// Dernier onglet REMPLISSABLE par le role : tabKeys exclut deja la Comptabilite
|
||||
// si l'user n'a pas accounting.view. Sa validation cloture l'ajout (redirection).
|
||||
const lastFillableTab = computed(() => tabKeys.value[tabKeys.value.length - 1])
|
||||
|
||||
/**
|
||||
* Apres validation d'un onglet (creation) : si c'est le dernier onglet du role,
|
||||
* l'ajout est termine -> toast final + retour au repertoire (miroir M1/M2) ; sinon
|
||||
* toast de mise a jour (l'onglet suivant a deja ete deverrouille par completeTab).
|
||||
*/
|
||||
function onTabSaved(key: string): void {
|
||||
if (key === lastFillableTab.value) {
|
||||
toast.success({ title: t('technique.providers.toast.addComplete') })
|
||||
router.push('/providers')
|
||||
return
|
||||
}
|
||||
toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||
}
|
||||
|
||||
// ── Onglet Contact ──────────────────────────────────────────────────────────
|
||||
/** Valide l'onglet Contact ; redirige si c'est le dernier onglet du role. */
|
||||
async function onSubmitContacts(): Promise<void> {
|
||||
const ok = await submitContacts(error => toast.error({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: apiErrorMessage(error),
|
||||
}))
|
||||
if (ok) {
|
||||
onTabSaved('contact')
|
||||
}
|
||||
}
|
||||
|
||||
function askRemoveContact(index: number): void {
|
||||
askConfirm(t('technique.providers.form.confirmDelete.contact'), () => removeContact(index))
|
||||
}
|
||||
|
||||
// ── Onglet Adresse ────────────────────────────────────────────────────────────
|
||||
// Contacts deja persistes (IRI non nul), rattachables a une adresse (M2M). Le
|
||||
// libelle reprend le nom complet, a defaut l'email.
|
||||
const contactOptions = computed<RefOption[]>(() =>
|
||||
contacts.value
|
||||
.filter(c => c.iri !== null)
|
||||
.map(c => ({
|
||||
value: c.iri as string,
|
||||
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
|
||||
})),
|
||||
)
|
||||
|
||||
// Pays : France garantie en tete meme si /countries echoue (resilience ERP-102),
|
||||
// pour rester preselectionnable par defaut sur chaque adresse.
|
||||
const countryOptions = computed<RefOption[]>(() => {
|
||||
const list = referentials.countries.value
|
||||
return list.some(c => c.value === 'France')
|
||||
? list
|
||||
: [{ value: 'France', label: 'France' }, ...list]
|
||||
})
|
||||
|
||||
const addressDegradedNotified = ref(false)
|
||||
|
||||
/** Avertit une seule fois quand l'autocompletion d'adresse bascule en degrade (RG-3.06). */
|
||||
function onAddressDegraded(): void {
|
||||
if (addressDegradedNotified.value) {
|
||||
return
|
||||
}
|
||||
addressDegradedNotified.value = true
|
||||
toast.warning({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: t('technique.providers.form.address.degraded'),
|
||||
})
|
||||
}
|
||||
|
||||
/** Valide l'onglet Adresse ; redirige si c'est le dernier onglet du role. */
|
||||
async function onSubmitAddresses(): Promise<void> {
|
||||
const ok = await submitAddresses(error => toast.error({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: apiErrorMessage(error),
|
||||
}))
|
||||
if (ok) {
|
||||
onTabSaved('address')
|
||||
}
|
||||
}
|
||||
|
||||
function askRemoveAddress(index: number): void {
|
||||
askConfirm(t('technique.providers.form.confirmDelete.address'), () => removeAddress(index))
|
||||
}
|
||||
|
||||
// ── Onglet Comptabilite ───────────────────────────────────────────────────────
|
||||
// Code stable du type de reglement selectionne (pour RG-3.07 / RG-3.08).
|
||||
const selectedPaymentTypeCode = computed(() =>
|
||||
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
|
||||
)
|
||||
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||
|
||||
// Les blocs RIB ne sont affiches que pour une LCR (RG-3.08).
|
||||
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
|
||||
|
||||
/** Changement de type de reglement : propage les RG inter-champs (banque / RIB). */
|
||||
function onPaymentTypeChange(value: string | number | null): void {
|
||||
const iri = value === null ? null : String(value)
|
||||
const code = referentials.paymentTypes.value.find(p => p.value === iri)?.code ?? null
|
||||
setPaymentType(iri, isBankRequiredForPaymentType(code), isRibRequiredForPaymentType(code))
|
||||
}
|
||||
|
||||
function askRemoveRib(index: number): void {
|
||||
askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index))
|
||||
}
|
||||
|
||||
/** Valide l'onglet Comptabilite ; redirige si c'est le dernier onglet du role. */
|
||||
async function onSubmitAccounting(): Promise<void> {
|
||||
const ok = await submitAccounting(
|
||||
isBankRequired.value,
|
||||
isRibRequired.value,
|
||||
error => toast.error({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: apiErrorMessage(error),
|
||||
}),
|
||||
)
|
||||
if (ok) {
|
||||
onTabSaved('accounting')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modal de confirmation generique ─────────────────────────────────────────
|
||||
const confirmModal = reactive({
|
||||
open: false,
|
||||
message: '',
|
||||
action: null as null | (() => void),
|
||||
})
|
||||
|
||||
function askConfirm(message: string, action: () => void): void {
|
||||
confirmModal.message = message
|
||||
confirmModal.action = action
|
||||
confirmModal.open = true
|
||||
}
|
||||
|
||||
function runConfirm(): void {
|
||||
confirmModal.action?.()
|
||||
confirmModal.action = null
|
||||
confirmModal.open = false
|
||||
}
|
||||
|
||||
// Icone (Iconify) affichee dans l'onglet, par cle.
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
contact: 'mdi:account-box-plus-outline',
|
||||
address: 'mdi:map-marker-outline',
|
||||
accounting: 'mdi:bank-circle-outline',
|
||||
}
|
||||
|
||||
// Onglets desactives tant que le formulaire principal n'est pas valide
|
||||
// (unlockedIndex = -1 au depart) ; deverrouillage progressif ensuite.
|
||||
const tabs = computed(() => tabKeys.value.map((key, index) => ({
|
||||
key,
|
||||
label: t(`technique.providers.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
disabled: index > unlockedIndex.value,
|
||||
})))
|
||||
|
||||
onMounted(() => {
|
||||
// Echec du chargement des referentiels non bloquant : les selects restent vides.
|
||||
referentials.loadMain().catch(() => {})
|
||||
// Referentiels comptables charges uniquement si l'onglet est accessible.
|
||||
if (canAccountingView.value) {
|
||||
referentials.loadAccounting().catch(() => {})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Types « brouillon » de l'ecran « Ajouter un prestataire » (M3 Technique).
|
||||
*
|
||||
* Miroir reduit de `types/supplierForm.ts` (M2) : le M3 n'a PAS d'onglet
|
||||
* Information, et porte en plus un selecteur de site SUR le formulaire principal
|
||||
* (RG-3.03 — relation directe `provider.sites`, distincte des sites d'adresse).
|
||||
*
|
||||
* Ces interfaces decrivent l'etat LOCAL du formulaire (refs Vue), distinct des
|
||||
* DTO de l'API : la page de creation (ERP-141) et — a venir — les blocs d'onglet
|
||||
* Contact / Adresse / Comptabilite (ERP-142 → 144) les partagent.
|
||||
*
|
||||
* Les relations M2M (categories, sites) sont portees par leurs IRI Hydra (`@id`),
|
||||
* envoyees telles quelles dans le payload POST (cf. contrat back ERP-139 :
|
||||
* `categories: ['/api/categories/{id}']`, `sites: ['/api/sites/{id}']`).
|
||||
*/
|
||||
|
||||
/** Etat « plat » du formulaire principal (groupe `provider:write:main`). */
|
||||
export interface ProviderMainDraft {
|
||||
/** Nom de l'entreprise prestataire. UPPERCASE serveur (RG-3.11), unicite RG-3.10. */
|
||||
companyName: string | null
|
||||
/** IRI des categories rattachees (M2M, type PRESTATAIRE — RG-3.09 ; >= 1). */
|
||||
categoryIris: string[]
|
||||
/** IRI des sites rattaches DIRECTEMENT au prestataire (M2M `provider_site`, RG-3.03 ; >= 1). */
|
||||
siteIris: string[]
|
||||
}
|
||||
|
||||
/** Fabrique un formulaire principal vierge. */
|
||||
export function emptyProviderMain(): ProviderMainDraft {
|
||||
return {
|
||||
companyName: null,
|
||||
categoryIris: [],
|
||||
siteIris: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** Reponse minimale du POST /providers exploitee par l'ecran de creation. */
|
||||
export interface ProviderMainResponse {
|
||||
id: number
|
||||
/** Nom renvoye normalise (UPPERCASE) par le serveur, reaffiche en lecture seule. */
|
||||
companyName: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Un contact du prestataire (onglet Contact, ERP-142). Miroir de
|
||||
* `SupplierContactFormDraft` (M2). Tous les champs sont nullable cote ORM ; la
|
||||
* validite (RG-3.04) tient a la presence d'AU MOINS un champ rempli parmi
|
||||
* prenom / nom / fonction / telephone principal / email (cf. back).
|
||||
*/
|
||||
export interface ProviderContactFormDraft {
|
||||
/** Id serveur une fois le contact cree (null tant que non persiste). */
|
||||
id: number | null
|
||||
/** IRI Hydra du contact cree — servira au rattachement M2M cote adresse (ERP-143). */
|
||||
iri: string | null
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
jobTitle: string | null
|
||||
phonePrimary: string | null
|
||||
phoneSecondary: string | null
|
||||
email: string | null
|
||||
/** UI : le 2e numero a ete revele via le bouton « + » (max 2 telephones). */
|
||||
hasSecondaryPhone: boolean
|
||||
}
|
||||
|
||||
/** Fabrique un contact vierge. */
|
||||
export function emptyProviderContact(): ProviderContactFormDraft {
|
||||
return {
|
||||
id: null,
|
||||
iri: null,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
jobTitle: null,
|
||||
phonePrimary: null,
|
||||
phoneSecondary: null,
|
||||
email: null,
|
||||
hasSecondaryPhone: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Reponse du POST /providers/{id}/contacts (groupe provider:item:read + IRI Hydra). */
|
||||
export interface ProviderContactResponse {
|
||||
'@id'?: string
|
||||
id: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Une adresse du prestataire (onglet Adresse, ERP-143). Version SIMPLIFIEE de
|
||||
* `SupplierAddressFormDraft` (M2) : PAS de type d'adresse (Prospect/Depart/Rendu),
|
||||
* PAS de bennes, PAS de prestation de triage. Champs postaux + M2M sites /
|
||||
* categories / contacts (par IRI).
|
||||
*/
|
||||
export interface ProviderAddressFormDraft {
|
||||
/** Id serveur une fois l'adresse creee (null tant que non persistee). */
|
||||
id: number | null
|
||||
/** Pays (chaine libre, defaut « France »). */
|
||||
country: string
|
||||
postalCode: string | null
|
||||
city: string | null
|
||||
street: string | null
|
||||
streetComplement: string | null
|
||||
/** IRI des categories rattachees (type PRESTATAIRE, RG-3.09 ; >= 1). */
|
||||
categoryIris: string[]
|
||||
/** IRI des sites rattaches a l'adresse (M2M `provider_address_site`, RG-3.05 ; >= 1). */
|
||||
siteIris: string[]
|
||||
/** IRI des contacts rattaches (= blocs Contact deja persistes de l'onglet Contact). */
|
||||
contactIris: string[]
|
||||
}
|
||||
|
||||
/** Fabrique une adresse vierge (France presaisi). */
|
||||
export function emptyProviderAddress(): ProviderAddressFormDraft {
|
||||
return {
|
||||
id: null,
|
||||
country: 'France',
|
||||
postalCode: null,
|
||||
city: null,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
categoryIris: [],
|
||||
siteIris: [],
|
||||
contactIris: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** Reponse du POST /providers/{id}/addresses (id suffisant pour le suivi cote front). */
|
||||
export interface ProviderAddressResponse {
|
||||
id: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Etat « plat » de l'onglet Comptabilite (groupe `provider:write:accounting`).
|
||||
* Relations (TVA / delai / type de reglement / banque) portees par leur IRI.
|
||||
*/
|
||||
export interface ProviderAccountingDraft {
|
||||
siren: string | null
|
||||
accountNumber: string | null
|
||||
tvaModeIri: string | null
|
||||
nTva: string | null
|
||||
paymentDelayIri: string | null
|
||||
paymentTypeIri: string | null
|
||||
/** Banque : requise et envoyee uniquement si Type de reglement = VIREMENT (RG-3.07). */
|
||||
bankIri: string | null
|
||||
}
|
||||
|
||||
/** Fabrique un onglet Comptabilite vierge. */
|
||||
export function emptyProviderAccounting(): ProviderAccountingDraft {
|
||||
return {
|
||||
siren: null,
|
||||
accountNumber: null,
|
||||
tvaModeIri: null,
|
||||
nTva: null,
|
||||
paymentDelayIri: null,
|
||||
paymentTypeIri: null,
|
||||
bankIri: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Un RIB du prestataire (sous-collection comptable, obligatoire si Type = LCR — RG-3.08). */
|
||||
export interface ProviderRibFormDraft {
|
||||
id: number | null
|
||||
label: string | null
|
||||
bic: string | null
|
||||
iban: string | null
|
||||
}
|
||||
|
||||
/** Fabrique un RIB vierge. */
|
||||
export function emptyProviderRib(): ProviderRibFormDraft {
|
||||
return {
|
||||
id: null,
|
||||
label: null,
|
||||
bic: null,
|
||||
iban: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Reponse du POST /providers/{id}/ribs (id suffisant pour le suivi cote front). */
|
||||
export interface ProviderRibResponse {
|
||||
id: number
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
buildProviderAccountingPayload,
|
||||
buildProviderRibPayload,
|
||||
isBankRequiredForPaymentType,
|
||||
isRibBlank,
|
||||
isRibComplete,
|
||||
isRibRequiredForPaymentType,
|
||||
} from '../providerAccounting'
|
||||
import { emptyProviderAccounting, emptyProviderRib } from '~/modules/technique/types/providerForm'
|
||||
|
||||
/**
|
||||
* Helpers purs de l'onglet Comptabilite prestataire (ERP-144) : RG inter-champs
|
||||
* RG-3.07 (banque si VIREMENT) / RG-3.08 (RIB si LCR) + construction des payloads.
|
||||
*/
|
||||
describe('providerAccounting helpers', () => {
|
||||
describe('RG-3.07 / RG-3.08 — type de reglement', () => {
|
||||
it('banque requise uniquement pour VIREMENT', () => {
|
||||
expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true)
|
||||
expect(isBankRequiredForPaymentType('LCR')).toBe(false)
|
||||
expect(isBankRequiredForPaymentType('CHEQUE')).toBe(false)
|
||||
expect(isBankRequiredForPaymentType(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('RIB requis uniquement pour LCR', () => {
|
||||
expect(isRibRequiredForPaymentType('LCR')).toBe(true)
|
||||
expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false)
|
||||
expect(isRibRequiredForPaymentType(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRibBlank / isRibComplete', () => {
|
||||
it('un RIB vierge est vide et incomplet', () => {
|
||||
expect(isRibBlank(emptyProviderRib())).toBe(true)
|
||||
expect(isRibComplete(emptyProviderRib())).toBe(false)
|
||||
})
|
||||
|
||||
it('un RIB partiel n\'est ni vide ni complet', () => {
|
||||
const rib = { ...emptyProviderRib(), iban: 'FR76...' }
|
||||
expect(isRibBlank(rib)).toBe(false)
|
||||
expect(isRibComplete(rib)).toBe(false)
|
||||
})
|
||||
|
||||
it('un RIB avec libelle + BIC + IBAN est complet', () => {
|
||||
const rib = { ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }
|
||||
expect(isRibComplete(rib)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildProviderAccountingPayload (RG-3.07)', () => {
|
||||
it('envoie la banque si requise (VIREMENT)', () => {
|
||||
const payload = buildProviderAccountingPayload({
|
||||
...emptyProviderAccounting(),
|
||||
paymentTypeIri: '/api/payment_types/3',
|
||||
bankIri: '/api/banks/2',
|
||||
}, true)
|
||||
expect(payload.bank).toBe('/api/banks/2')
|
||||
expect(payload.paymentType).toBe('/api/payment_types/3')
|
||||
})
|
||||
|
||||
it('force la banque a null si non requise (hors VIREMENT)', () => {
|
||||
const payload = buildProviderAccountingPayload({
|
||||
...emptyProviderAccounting(),
|
||||
bankIri: '/api/banks/2',
|
||||
}, false)
|
||||
expect(payload.bank).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildProviderRibPayload', () => {
|
||||
it('omet les champs requis vides (NotBlank back joue sur le champ)', () => {
|
||||
const payload = buildProviderRibPayload(emptyProviderRib())
|
||||
expect(payload).not.toHaveProperty('label')
|
||||
expect(payload).not.toHaveProperty('bic')
|
||||
expect(payload).not.toHaveProperty('iban')
|
||||
})
|
||||
|
||||
it('conserve les champs remplis', () => {
|
||||
const payload = buildProviderRibPayload({ ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' })
|
||||
expect(payload).toEqual({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
buildProviderAddressPayload,
|
||||
isProviderAddressValid,
|
||||
} from '../providerAddress'
|
||||
import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
|
||||
|
||||
/**
|
||||
* Helpers purs de l'onglet Adresse prestataire (ERP-143). RG-3.05 (>= 1 site) et
|
||||
* construction du payload de sous-ressource (relations en IRI, requis vides omis,
|
||||
* pas de type d'adresse / bennes / triage — difference M2).
|
||||
*/
|
||||
describe('providerAddress helpers', () => {
|
||||
const SITE = '/api/sites/1'
|
||||
const CAT = '/api/categories/7'
|
||||
|
||||
describe('isProviderAddressValid (RG-3.05 / RG-3.09)', () => {
|
||||
it('false sans site', () => {
|
||||
const address = { ...emptyProviderAddress(), categoryIris: [CAT] }
|
||||
expect(isProviderAddressValid(address)).toBe(false)
|
||||
})
|
||||
|
||||
it('false sans categorie', () => {
|
||||
const address = { ...emptyProviderAddress(), siteIris: [SITE] }
|
||||
expect(isProviderAddressValid(address)).toBe(false)
|
||||
})
|
||||
|
||||
it('true avec au moins un site ET une categorie', () => {
|
||||
const address = { ...emptyProviderAddress(), siteIris: [SITE], categoryIris: [CAT] }
|
||||
expect(isProviderAddressValid(address)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildProviderAddressPayload', () => {
|
||||
it('mappe les relations en IRI et n\'embarque PAS type/bennes/triage (difference M2)', () => {
|
||||
const payload = buildProviderAddressPayload({
|
||||
...emptyProviderAddress(),
|
||||
postalCode: '86100',
|
||||
city: 'Châtellerault',
|
||||
street: '1 rue du Test',
|
||||
siteIris: [SITE],
|
||||
categoryIris: [CAT],
|
||||
contactIris: ['/api/provider_contacts/9'],
|
||||
})
|
||||
expect(payload).toEqual({
|
||||
country: 'France',
|
||||
postalCode: '86100',
|
||||
city: 'Châtellerault',
|
||||
street: '1 rue du Test',
|
||||
streetComplement: null,
|
||||
categories: [CAT],
|
||||
sites: [SITE],
|
||||
contacts: ['/api/provider_contacts/9'],
|
||||
})
|
||||
expect(payload).not.toHaveProperty('addressType')
|
||||
expect(payload).not.toHaveProperty('bennes')
|
||||
expect(payload).not.toHaveProperty('triageProvider')
|
||||
})
|
||||
|
||||
it('omet les scalaires requis vides (NotBlank back joue sur le champ)', () => {
|
||||
const payload = buildProviderAddressPayload({
|
||||
...emptyProviderAddress(),
|
||||
siteIris: [SITE],
|
||||
categoryIris: [CAT],
|
||||
})
|
||||
expect(payload).not.toHaveProperty('postalCode')
|
||||
expect(payload).not.toHaveProperty('city')
|
||||
expect(payload).not.toHaveProperty('street')
|
||||
// streetComplement n'est PAS requis -> reste present a null.
|
||||
expect(payload).toHaveProperty('streetComplement', null)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
buildProviderContactPayload,
|
||||
hasAtLeastOneFilledContact,
|
||||
isProviderContactBlank,
|
||||
isProviderContactNamed,
|
||||
} from '../providerContact'
|
||||
import { emptyProviderContact } from '~/modules/technique/types/providerForm'
|
||||
|
||||
/**
|
||||
* Helpers purs de l'onglet Contact prestataire (ERP-142). On verifie la
|
||||
* definition de « bloc vide » (RG-3.04, alignee sur le back) et la construction
|
||||
* du payload de sous-ressource.
|
||||
*/
|
||||
describe('providerContact helpers', () => {
|
||||
describe('isProviderContactBlank (RG-3.04)', () => {
|
||||
it('un bloc vierge est vide', () => {
|
||||
expect(isProviderContactBlank(emptyProviderContact())).toBe(true)
|
||||
})
|
||||
|
||||
it('un seul champ rempli parmi nom/prenom/fonction/tel/email suffit a le rendre non vide', () => {
|
||||
for (const field of ['firstName', 'lastName', 'jobTitle', 'phonePrimary', 'email'] as const) {
|
||||
const contact = { ...emptyProviderContact(), [field]: 'x' }
|
||||
expect(isProviderContactBlank(contact)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('ignore les espaces (trim) — un champ blanc ne compte pas', () => {
|
||||
expect(isProviderContactBlank({ ...emptyProviderContact(), lastName: ' ' })).toBe(true)
|
||||
})
|
||||
|
||||
it('un 2e telephone seul NE suffit PAS (exclu, comme le back)', () => {
|
||||
const contact = { ...emptyProviderContact(), hasSecondaryPhone: true, phoneSecondary: '0102030405' }
|
||||
expect(isProviderContactBlank(contact)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isProviderContactNamed (RG-3.04 — prenom OU nom)', () => {
|
||||
it('vrai avec un prenom seul ou un nom seul', () => {
|
||||
expect(isProviderContactNamed({ ...emptyProviderContact(), firstName: 'Jean' })).toBe(true)
|
||||
expect(isProviderContactNamed({ ...emptyProviderContact(), lastName: 'Dupont' })).toBe(true)
|
||||
})
|
||||
|
||||
it('faux si seuls fonction / telephone / email sont remplis (ne suffit pas)', () => {
|
||||
expect(isProviderContactNamed({ ...emptyProviderContact(), jobTitle: 'Directeur' })).toBe(false)
|
||||
expect(isProviderContactNamed({ ...emptyProviderContact(), email: 'a@b.fr' })).toBe(false)
|
||||
expect(isProviderContactNamed({ ...emptyProviderContact(), phonePrimary: '0102030405' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAtLeastOneFilledContact (RG-3.12 — au moins un contact nomme)', () => {
|
||||
it('false si aucun bloc n\'est nomme', () => {
|
||||
expect(hasAtLeastOneFilledContact([emptyProviderContact(), { ...emptyProviderContact(), email: 'a@b.fr' }])).toBe(false)
|
||||
})
|
||||
|
||||
it('true des qu\'un bloc porte un nom ou prenom', () => {
|
||||
expect(hasAtLeastOneFilledContact([
|
||||
emptyProviderContact(),
|
||||
{ ...emptyProviderContact(), lastName: 'Dupont' },
|
||||
])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildProviderContactPayload', () => {
|
||||
it('mappe les champs et envoie null pour les vides', () => {
|
||||
const payload = buildProviderContactPayload({ ...emptyProviderContact(), lastName: 'Doe' })
|
||||
expect(payload).toEqual({
|
||||
firstName: null,
|
||||
lastName: 'Doe',
|
||||
jobTitle: null,
|
||||
phonePrimary: null,
|
||||
phoneSecondary: null,
|
||||
email: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('n\'envoie le 2e telephone que si revele (max 2)', () => {
|
||||
const masque = buildProviderContactPayload({
|
||||
...emptyProviderContact(),
|
||||
phoneSecondary: '0102030405',
|
||||
hasSecondaryPhone: false,
|
||||
})
|
||||
expect(masque.phoneSecondary).toBeNull()
|
||||
|
||||
const revele = buildProviderContactPayload({
|
||||
...emptyProviderContact(),
|
||||
phoneSecondary: '0102030405',
|
||||
hasSecondaryPhone: true,
|
||||
})
|
||||
expect(revele.phoneSecondary).toBe('0102030405')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// formatPhoneFR est auto-importe dans le helper via le chemin partage ; on le mocke
|
||||
// pour un rendu deterministe (la mise en forme exacte est testee ailleurs).
|
||||
vi.mock('~/shared/utils/phone', () => ({
|
||||
formatPhoneFR: (v: string) => `fmt(${v})`,
|
||||
}))
|
||||
|
||||
const {
|
||||
canEditProvider,
|
||||
categoryOptionsOf,
|
||||
contactOptionsOf,
|
||||
iriOf,
|
||||
irisOf,
|
||||
mapAccountingDraft,
|
||||
mapAddressToDraft,
|
||||
mapContactToDraft,
|
||||
mapRibToDraft,
|
||||
paymentTypeCodeOf,
|
||||
referentialOptionOf,
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
siteOptionsOf,
|
||||
} = await import('../providerDetail')
|
||||
|
||||
/**
|
||||
* Helpers purs des ecrans Consultation / Modification (ERP-145) : mapping du
|
||||
* detail embarque vers les brouillons + regles d'affichage des actions (Modifier /
|
||||
* Archiver / Restaurer).
|
||||
*/
|
||||
describe('providerDetail helpers', () => {
|
||||
describe('iriOf / irisOf', () => {
|
||||
it('extrait l\'IRI d\'un objet embarque, d\'un IRI nu, ou null', () => {
|
||||
expect(iriOf({ '@id': '/api/banks/2' })).toBe('/api/banks/2')
|
||||
expect(iriOf('/api/banks/2')).toBe('/api/banks/2')
|
||||
expect(iriOf(null)).toBeNull()
|
||||
expect(iriOf(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('extrait les IRI d\'une collection embarquee', () => {
|
||||
expect(irisOf([{ '@id': '/api/sites/1' }, { '@id': '/api/sites/2' }])).toEqual(['/api/sites/1', '/api/sites/2'])
|
||||
expect(irisOf(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapContactToDraft', () => {
|
||||
it('mappe les champs, formate les telephones et derive hasSecondaryPhone', () => {
|
||||
const draft = mapContactToDraft({
|
||||
'@id': '/api/provider_contacts/5',
|
||||
id: 5,
|
||||
firstName: 'Jean',
|
||||
lastName: 'Dupont',
|
||||
phonePrimary: '0102030405',
|
||||
phoneSecondary: '0607080910',
|
||||
email: 'jean@x.fr',
|
||||
})
|
||||
expect(draft).toMatchObject({
|
||||
id: 5,
|
||||
iri: '/api/provider_contacts/5',
|
||||
firstName: 'Jean',
|
||||
lastName: 'Dupont',
|
||||
phonePrimary: 'fmt(0102030405)',
|
||||
phoneSecondary: 'fmt(0607080910)',
|
||||
email: 'jean@x.fr',
|
||||
hasSecondaryPhone: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('hasSecondaryPhone faux sans 2e numero', () => {
|
||||
const draft = mapContactToDraft({ '@id': '/api/provider_contacts/6', id: 6, lastName: 'Doe' })
|
||||
expect(draft.hasSecondaryPhone).toBe(false)
|
||||
expect(draft.phoneSecondary).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapAddressToDraft', () => {
|
||||
it('extrait les IRI des sites / categories / contacts embarques', () => {
|
||||
const draft = mapAddressToDraft({
|
||||
'@id': '/api/provider_addresses/3',
|
||||
id: 3,
|
||||
country: 'France',
|
||||
postalCode: '86100',
|
||||
city: 'Châtellerault',
|
||||
street: '1 rue du Test',
|
||||
sites: [{ '@id': '/api/sites/1' }],
|
||||
categories: [{ '@id': '/api/categories/7' }],
|
||||
contacts: [{ '@id': '/api/provider_contacts/5' }, '/api/provider_contacts/6'],
|
||||
})
|
||||
expect(draft.siteIris).toEqual(['/api/sites/1'])
|
||||
expect(draft.categoryIris).toEqual(['/api/categories/7'])
|
||||
expect(draft.contactIris).toEqual(['/api/provider_contacts/5', '/api/provider_contacts/6'])
|
||||
expect(draft.id).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapAccountingDraft / mapRibToDraft', () => {
|
||||
it('mappe les scalaires et les IRI des referentiels embarques', () => {
|
||||
const draft = mapAccountingDraft({
|
||||
'@id': '/api/providers/9',
|
||||
id: 9,
|
||||
siren: '123456789',
|
||||
accountNumber: '4010',
|
||||
nTva: 'FR123',
|
||||
tvaMode: { '@id': '/api/tva_modes/1', label: 'TVA' },
|
||||
paymentType: { '@id': '/api/payment_types/3', code: 'VIREMENT' },
|
||||
bank: { '@id': '/api/banks/2' },
|
||||
})
|
||||
expect(draft.tvaModeIri).toBe('/api/tva_modes/1')
|
||||
expect(draft.paymentTypeIri).toBe('/api/payment_types/3')
|
||||
expect(draft.bankIri).toBe('/api/banks/2')
|
||||
expect(draft.paymentDelayIri).toBeNull()
|
||||
expect(draft.siren).toBe('123456789')
|
||||
})
|
||||
|
||||
it('mappe un RIB embarque', () => {
|
||||
expect(mapRibToDraft({ '@id': '/api/provider_ribs/1', id: 1, label: 'Compte', bic: 'BIC', iban: 'IBAN' }))
|
||||
.toEqual({ id: 1, label: 'Compte', bic: 'BIC', iban: 'IBAN' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('options builders (libelles role-independants depuis l\'embed)', () => {
|
||||
it('categoryOptionsOf / siteOptionsOf / contactOptionsOf', () => {
|
||||
expect(categoryOptionsOf([{ '@id': '/api/categories/7', name: 'Maintenance', code: 'MAINT' }]))
|
||||
.toEqual([{ value: '/api/categories/7', label: 'Maintenance' }])
|
||||
expect(siteOptionsOf([{ '@id': '/api/sites/1', name: 'Châtellerault' }]))
|
||||
.toEqual([{ value: '/api/sites/1', label: 'Châtellerault' }])
|
||||
expect(contactOptionsOf([{ '@id': '/api/provider_contacts/5', id: 5, firstName: 'Jean', lastName: 'Dupont' }]))
|
||||
.toEqual([{ value: '/api/provider_contacts/5', label: 'Jean Dupont' }])
|
||||
})
|
||||
|
||||
it('referentialOptionOf / paymentTypeCodeOf', () => {
|
||||
expect(referentialOptionOf({ '@id': '/api/banks/2', label: 'SG' }))
|
||||
.toEqual([{ value: '/api/banks/2', label: 'SG' }])
|
||||
expect(referentialOptionOf(null)).toEqual([])
|
||||
expect(referentialOptionOf('/api/banks/2')).toEqual([])
|
||||
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/3', code: 'LCR' })).toBe('LCR')
|
||||
expect(paymentTypeCodeOf(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('actions selon permissions', () => {
|
||||
/** Fabrique un `can` qui n'autorise que les codes fournis. */
|
||||
const canFor = (granted: string[]) => (code: string) => granted.includes(code)
|
||||
const canAnyFor = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c))
|
||||
|
||||
it('« Modifier » visible avec manage OU accounting.manage (Compta inclus)', () => {
|
||||
expect(canEditProvider(canAnyFor(['technique.providers.manage']))).toBe(true)
|
||||
expect(canEditProvider(canAnyFor(['technique.providers.accounting.manage']))).toBe(true)
|
||||
expect(canEditProvider(canAnyFor(['technique.providers.view']))).toBe(false)
|
||||
})
|
||||
|
||||
it('« Archiver » visible seulement avec archive ET prestataire actif (Admin seul)', () => {
|
||||
const admin = canFor(['technique.providers.archive'])
|
||||
const bureau = canFor(['technique.providers.manage'])
|
||||
expect(showArchiveAction(admin, false)).toBe(true)
|
||||
expect(showArchiveAction(admin, true)).toBe(false) // deja archive -> Restaurer
|
||||
expect(showArchiveAction(bureau, false)).toBe(false) // pas la permission archive
|
||||
})
|
||||
|
||||
it('« Restaurer » visible seulement avec archive ET prestataire archive', () => {
|
||||
const admin = canFor(['technique.providers.archive'])
|
||||
expect(showRestoreAction(admin, true)).toBe(true)
|
||||
expect(showRestoreAction(admin, false)).toBe(false)
|
||||
expect(showRestoreAction(canFor([]), true)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Helpers purs de l'onglet Comptabilite prestataire (M3 Technique, ERP-144) —
|
||||
* miroir SIMPLIFIE des regles M2, reimplemente cote module Technique (regle
|
||||
* ABSOLUE n°1 : pas d'import inter-module). Portent les RG inter-champs RG-3.07
|
||||
* (banque si VIREMENT) et RG-3.08 (RIB si LCR), testables sans Vue ni API.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ProviderAccountingDraft,
|
||||
ProviderRibFormDraft,
|
||||
} from '~/modules/technique/types/providerForm'
|
||||
|
||||
/** Code pivot du type de reglement imposant une banque (RG-3.07). */
|
||||
const PAYMENT_TYPE_VIREMENT = 'VIREMENT'
|
||||
/** Code pivot du type de reglement imposant au moins un RIB (RG-3.08). */
|
||||
const PAYMENT_TYPE_LCR = 'LCR'
|
||||
|
||||
/** Champs RIB obligatoires non nullable cote back (NotBlank) — omis si vides au POST. */
|
||||
const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const
|
||||
|
||||
/** Vrai si une chaine porte au moins un caractere non-espace. */
|
||||
function isFilled(value: string | null | undefined): boolean {
|
||||
return value !== null && value !== undefined && value.trim() !== ''
|
||||
}
|
||||
|
||||
/** RG-3.07 : la banque n'est requise/visible que pour un reglement par VIREMENT. */
|
||||
export function isBankRequiredForPaymentType(code: string | null | undefined): boolean {
|
||||
return code === PAYMENT_TYPE_VIREMENT
|
||||
}
|
||||
|
||||
/** RG-3.08 : au moins un RIB n'est requis que pour un reglement par LCR. */
|
||||
export function isRibRequiredForPaymentType(code: string | null | undefined): boolean {
|
||||
return code === PAYMENT_TYPE_LCR
|
||||
}
|
||||
|
||||
/** Vrai si AUCUN champ du bloc RIB n'est rempli (amorce vide a ignorer au submit). */
|
||||
export function isRibBlank(rib: ProviderRibFormDraft): boolean {
|
||||
return ![rib.label, rib.bic, rib.iban].some(isFilled)
|
||||
}
|
||||
|
||||
/** Vrai si les 3 champs du RIB sont remplis (gating « + RIB »). */
|
||||
export function isRibComplete(rib: ProviderRibFormDraft): boolean {
|
||||
return isFilled(rib.label) && isFilled(rib.bic) && isFilled(rib.iban)
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload du PATCH comptable (groupe `provider:write:accounting`). Les relations
|
||||
* sont en IRI ; la banque n'est envoyee que si elle est requise (RG-3.07), sinon
|
||||
* `null` (le back vide la relation hors VIREMENT).
|
||||
*/
|
||||
export function buildProviderAccountingPayload(
|
||||
accounting: ProviderAccountingDraft,
|
||||
isBankRequired: boolean,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
siren: accounting.siren || null,
|
||||
accountNumber: accounting.accountNumber || null,
|
||||
tvaMode: accounting.tvaModeIri,
|
||||
nTva: accounting.nTva || null,
|
||||
paymentDelay: accounting.paymentDelayIri,
|
||||
paymentType: accounting.paymentTypeIri,
|
||||
bank: isBankRequired ? accounting.bankIri : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload d'un RIB (sous-ressource, groupe `provider:write:accounting`). Les
|
||||
* champs requis vides sont omis a la creation pour que la 422 NotBlank porte sur
|
||||
* le champ.
|
||||
*/
|
||||
export function buildProviderRibPayload(rib: ProviderRibFormDraft): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
label: rib.label,
|
||||
bic: rib.bic,
|
||||
iban: rib.iban,
|
||||
}
|
||||
|
||||
for (const key of RIB_REQUIRED_NON_NULLABLE_KEYS) {
|
||||
const value = payload[key]
|
||||
if (value === null || value === undefined || value === '') {
|
||||
delete payload[key]
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Helpers purs de l'onglet Adresse prestataire (M3 Technique, ERP-143) — miroir
|
||||
* SIMPLIFIE de `supplierFormRules`/`supplierEdit` (M2), reimplemente cote module
|
||||
* Technique (regle ABSOLUE n°1 : pas d'import inter-module). Testables sans Vue.
|
||||
*/
|
||||
|
||||
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
|
||||
|
||||
/**
|
||||
* Champs scalaires obligatoires non nullable cote back (NotBlank). A la creation
|
||||
* (POST), on OMET du payload ceux qui sont vides pour que la 422 porte la
|
||||
* violation NotBlank propre (sur le champ) plutot qu'une erreur de type.
|
||||
*/
|
||||
const REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
|
||||
|
||||
/**
|
||||
* RG-3.05 (+ RG-3.09) : une adresse est « valide » pour autoriser l'ajout d'un
|
||||
* nouveau bloc des qu'elle porte au moins un site ET au moins une categorie. Les
|
||||
* scalaires (CP/ville/rue) restent valides par le back (422 inline).
|
||||
*/
|
||||
export function isProviderAddressValid(address: ProviderAddressFormDraft): boolean {
|
||||
return address.siteIris.length >= 1 && address.categoryIris.length >= 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload de la sous-ressource addresses (groupe `provider:write:addresses`).
|
||||
* Relations M2M en IRI. Les scalaires requis vides sont omis a la creation (cf.
|
||||
* REQUIRED_NON_NULLABLE_KEYS).
|
||||
*/
|
||||
export function buildProviderAddressPayload(address: ProviderAddressFormDraft): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
country: address.country,
|
||||
postalCode: address.postalCode || null,
|
||||
city: address.city || null,
|
||||
street: address.street || null,
|
||||
streetComplement: address.streetComplement || null,
|
||||
categories: [...address.categoryIris],
|
||||
sites: [...address.siteIris],
|
||||
contacts: [...address.contactIris],
|
||||
}
|
||||
|
||||
for (const key of REQUIRED_NON_NULLABLE_KEYS) {
|
||||
const value = payload[key]
|
||||
if (value === null || value === undefined || value === '') {
|
||||
delete payload[key]
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Helpers purs de l'onglet Contact prestataire (M3 Technique, ERP-142) — miroir
|
||||
* reduit de `supplierFormRules.ts` / `supplierEdit.ts` (M2). Testables sans Vue
|
||||
* ni API : detection de bloc vide (RG-3.04) et construction du payload de
|
||||
* sous-ressource contacts.
|
||||
*/
|
||||
|
||||
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
|
||||
|
||||
/** Vrai si une chaine porte au moins un caractere non-espace. */
|
||||
function isFilled(value: string | null | undefined): boolean {
|
||||
return value !== null && value !== undefined && value.trim() !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.04 : un bloc Contact est VIDE tant qu'aucun des champs comptant pour la
|
||||
* validite n'est rempli — prenom / nom / fonction / telephone principal / email.
|
||||
*
|
||||
* `phoneSecondary` est volontairement EXCLU : le back (CHECK
|
||||
* `chk_provider_contact_name` + `ProviderContactProcessor`) ne le compte pas non
|
||||
* plus, un bloc ne portant qu'un 2e numero reste invalide. Garder la meme
|
||||
* definition cote front evite tout drift (un bloc « vide » front == bloc rejete
|
||||
* back).
|
||||
*/
|
||||
export function isProviderContactBlank(contact: ProviderContactFormDraft): boolean {
|
||||
return ![
|
||||
contact.firstName,
|
||||
contact.lastName,
|
||||
contact.jobTitle,
|
||||
contact.phonePrimary,
|
||||
contact.email,
|
||||
].some(isFilled)
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.04 : un contact est « nomme » (valide) des qu'il porte un prenom OU un nom
|
||||
* — aligne sur le M1/M2. Sert le gating « + Nouveau contact » et la notion de
|
||||
* contact valide (la fonction / le telephone / l'email seuls ne suffisent pas).
|
||||
*/
|
||||
export function isProviderContactNamed(contact: ProviderContactFormDraft): boolean {
|
||||
return isFilled(contact.firstName) || isFilled(contact.lastName)
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.12 : l'onglet Contact ne peut etre finalise que s'il reste au moins un
|
||||
* contact nomme (prenom ou nom).
|
||||
*/
|
||||
export function hasAtLeastOneFilledContact(contacts: ProviderContactFormDraft[]): boolean {
|
||||
return contacts.some(isProviderContactNamed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload de la sous-ressource contacts (groupe `provider:write:contacts`). Les
|
||||
* chaines vides sont envoyees a null (le serveur normalise/trim de toute facon).
|
||||
* `phoneSecondary` n'est envoye que si le 2e numero a ete revele (max 2 tel).
|
||||
*/
|
||||
export function buildProviderContactPayload(contact: ProviderContactFormDraft): Record<string, unknown> {
|
||||
return {
|
||||
firstName: contact.firstName || null,
|
||||
lastName: contact.lastName || null,
|
||||
jobTitle: contact.jobTitle || null,
|
||||
phonePrimary: contact.phonePrimary || null,
|
||||
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
|
||||
email: contact.email || null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Helpers purs des ecrans Consultation / Modification prestataire (M3 Technique,
|
||||
* ERP-145) — miroir SIMPLIFIE de `supplierConsultation.ts` (M2). Mappent le payload
|
||||
* `GET /api/providers/{id}` (relations embarquees, cf. groupes `provider:item:read`
|
||||
* + `provider:read:accounting`) vers les brouillons « plats » partages avec
|
||||
* `ProviderContactBlock` / `ProviderAddressBlock` et l'onglet Comptabilite.
|
||||
*
|
||||
* Ne touchent ni a l'API ni a l'etat reactif (testables unitairement).
|
||||
*
|
||||
* Rappels de contrat back (JSON reel fige — ERP-139, spec-back § 4.0.bis) :
|
||||
* - categories / sites du prestataire et des adresses : OBJETS embarques (avec @id) ;
|
||||
* - refs comptables (tvaMode/paymentDelay/paymentType/bank) : OBJETS embarques
|
||||
* `{@id, id, label, (code pour paymentType)}` ;
|
||||
* - champs nuls OMIS (skip_null_values) → toujours lire avec `?? null` ;
|
||||
* - champs comptables + `ribs` TOTALEMENT ABSENTS sans permission accounting.view.
|
||||
*
|
||||
* Differences M2 : pas de type d'adresse / bennes / triage, pas d'onglet Information.
|
||||
*/
|
||||
|
||||
import { formatPhoneFR } from '~/shared/utils/phone'
|
||||
import type {
|
||||
ProviderAccountingDraft,
|
||||
ProviderAddressFormDraft,
|
||||
ProviderContactFormDraft,
|
||||
ProviderRibFormDraft,
|
||||
} from '~/modules/technique/types/providerForm'
|
||||
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||
|
||||
/** Reference Hydra embarquee minimale (@id toujours present). */
|
||||
export interface HydraRef {
|
||||
'@id': string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */
|
||||
export type Relation = HydraRef | string | null | undefined
|
||||
|
||||
/** Site embarque (groupe site:read). */
|
||||
export interface SiteRead extends HydraRef {
|
||||
name?: string
|
||||
postalCode?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
/** Categorie embarquee (groupe category:read). */
|
||||
export interface CategoryRead extends HydraRef {
|
||||
code?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** Contact embarque (groupe provider:item:read). */
|
||||
export interface ContactRead extends HydraRef {
|
||||
id: number
|
||||
firstName?: string | null
|
||||
lastName?: string | null
|
||||
jobTitle?: string | null
|
||||
phonePrimary?: string | null
|
||||
phoneSecondary?: string | null
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
/** Adresse embarquee (groupe provider:item:read) — version simplifiee M3. */
|
||||
export interface AddressRead extends HydraRef {
|
||||
id: number
|
||||
country?: string | null
|
||||
postalCode?: string | null
|
||||
city?: string | null
|
||||
street?: string | null
|
||||
streetComplement?: string | null
|
||||
sites?: SiteRead[]
|
||||
categories?: CategoryRead[]
|
||||
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
|
||||
contacts?: Array<HydraRef | string>
|
||||
}
|
||||
|
||||
/** RIB embarque (groupe provider:read:accounting, present ssi accounting.view). */
|
||||
export interface RibRead extends HydraRef {
|
||||
id: number
|
||||
label?: string | null
|
||||
bic?: string | null
|
||||
iban?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail d'un prestataire (`GET /api/providers/{id}`). Tous les champs sont
|
||||
* optionnels : skip_null_values + gating accounting peuvent omettre n'importe
|
||||
* quelle cle.
|
||||
*/
|
||||
export interface ProviderDetail extends HydraRef {
|
||||
id: number
|
||||
companyName?: string | null
|
||||
isArchived?: boolean
|
||||
categories?: CategoryRead[]
|
||||
sites?: SiteRead[]
|
||||
contacts?: ContactRead[]
|
||||
addresses?: AddressRead[]
|
||||
ribs?: RibRead[]
|
||||
// Onglet Comptabilite (present ssi accounting.view)
|
||||
siren?: string | null
|
||||
accountNumber?: string | null
|
||||
nTva?: string | null
|
||||
tvaMode?: Relation
|
||||
paymentDelay?: Relation
|
||||
paymentType?: Relation
|
||||
bank?: Relation
|
||||
}
|
||||
|
||||
/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */
|
||||
export function iriOf(relation: Relation): string | null {
|
||||
if (relation === null || relation === undefined) {
|
||||
return null
|
||||
}
|
||||
if (typeof relation === 'string') {
|
||||
return relation
|
||||
}
|
||||
return relation['@id'] ?? null
|
||||
}
|
||||
|
||||
/** IRI des elements d'une collection embarquee (categories / sites du prestataire). */
|
||||
export function irisOf(items: HydraRef[] | undefined): string[] {
|
||||
return (items ?? []).map(i => i['@id'])
|
||||
}
|
||||
|
||||
/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */
|
||||
export function mapContactToDraft(contact: ContactRead): ProviderContactFormDraft {
|
||||
const phoneSecondary = contact.phoneSecondary ?? null
|
||||
return {
|
||||
id: contact.id,
|
||||
iri: contact['@id'] ?? null,
|
||||
firstName: contact.firstName ?? null,
|
||||
lastName: contact.lastName ?? null,
|
||||
jobTitle: contact.jobTitle ?? null,
|
||||
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
|
||||
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
|
||||
email: contact.email ?? null,
|
||||
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections). */
|
||||
export function mapAddressToDraft(address: AddressRead): ProviderAddressFormDraft {
|
||||
return {
|
||||
id: address.id,
|
||||
country: address.country ?? 'France',
|
||||
postalCode: address.postalCode ?? null,
|
||||
city: address.city ?? null,
|
||||
street: address.street ?? null,
|
||||
streetComplement: address.streetComplement ?? null,
|
||||
categoryIris: (address.categories ?? []).map(c => c['@id']),
|
||||
siteIris: (address.sites ?? []).map(s => s['@id']),
|
||||
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe un RIB embarque vers un brouillon. */
|
||||
export function mapRibToDraft(rib: RibRead): ProviderRibFormDraft {
|
||||
return {
|
||||
id: rib.id,
|
||||
label: rib.label ?? null,
|
||||
bic: rib.bic ?? null,
|
||||
iban: rib.iban ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe les champs comptables (scalaires + IRI des referentiels embarques). */
|
||||
export function mapAccountingDraft(provider: ProviderDetail): ProviderAccountingDraft {
|
||||
return {
|
||||
siren: provider.siren ?? null,
|
||||
accountNumber: provider.accountNumber ?? null,
|
||||
nTva: provider.nTva ?? null,
|
||||
tvaModeIri: iriOf(provider.tvaMode),
|
||||
paymentDelayIri: iriOf(provider.paymentDelay),
|
||||
paymentTypeIri: iriOf(provider.paymentType),
|
||||
bankIri: iriOf(provider.bank),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options de categories (value=IRI, label=nom) construites depuis l'embed.
|
||||
* Source role-independante : evite de dependre de `GET /categories` (403 possible
|
||||
* pour un role metier), qui laisserait les libelles vides en consultation.
|
||||
*/
|
||||
export function categoryOptionsOf(categories: CategoryRead[] | undefined): RefOption[] {
|
||||
return (categories ?? []).map(c => ({
|
||||
value: c['@id'],
|
||||
label: c.name ?? c.code ?? c['@id'],
|
||||
}))
|
||||
}
|
||||
|
||||
/** Options de sites (value=IRI, label=nom) construites depuis un embed. */
|
||||
export function siteOptionsOf(sites: SiteRead[] | undefined): RefOption[] {
|
||||
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
|
||||
}
|
||||
|
||||
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed prestataire. */
|
||||
export function contactOptionsOf(contacts: ContactRead[] | undefined): RefOption[] {
|
||||
return (contacts ?? []).map(c => ({
|
||||
value: c['@id'],
|
||||
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste a une seule option (ou vide) construite depuis un referentiel embarque
|
||||
* (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en
|
||||
* lecture seule. Le libelle vient de l'embed, jamais d'un GET de referentiel —
|
||||
* l'affichage reste correct quel que soit le role.
|
||||
*/
|
||||
export function referentialOptionOf(relation: Relation): RefOption[] {
|
||||
if (!relation || typeof relation === 'string') {
|
||||
return []
|
||||
}
|
||||
const label = (relation.label as string | undefined)
|
||||
?? (relation.name as string | undefined)
|
||||
?? relation['@id']
|
||||
return [{ value: relation['@id'], label }]
|
||||
}
|
||||
|
||||
/** Code metier d'un referentiel embarque (PaymentType.code = 'LCR' / 'VIREMENT'), ou null. */
|
||||
export function paymentTypeCodeOf(relation: Relation): string | null {
|
||||
if (!relation || typeof relation === 'string') {
|
||||
return null
|
||||
}
|
||||
return (relation.code as string | undefined) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet —
|
||||
* `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir
|
||||
* ouvrir l'edition pour son onglet Comptabilite). Le readonly fin par onglet est
|
||||
* gere sur l'ecran d'edition.
|
||||
*/
|
||||
export function canEditProvider(canAny: (codes: string[]) => boolean): boolean {
|
||||
return canAny(['technique.providers.manage', 'technique.providers.accounting.manage'])
|
||||
}
|
||||
|
||||
/** Bouton « Archiver » : permission archive ET prestataire encore actif (Admin seul). */
|
||||
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
||||
return can('technique.providers.archive') && !isArchived
|
||||
}
|
||||
|
||||
/** Bouton « Restaurer » : permission archive ET prestataire deja archive (Admin seul). */
|
||||
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
||||
return can('technique.providers.archive') && isArchived
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
||||
"name": "starseed-frontend",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.7.8",
|
||||
"@malio/layer-ui": "^1.7.10",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -1866,9 +1866,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.7.8",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.8/layer-ui-1.7.8.tgz",
|
||||
"integrity": "sha512-gUMAZzBsPCfQUF3OQSjN/OFzjONvQZYfwqH0u5VUbxaqwBdX1hUGtjD4ym6RvZkyNsKulrxkncFZYTWCS+IdGA==",
|
||||
"version": "1.7.10",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.10/layer-ui-1.7.10.tgz",
|
||||
"integrity": "sha512-ZWYaKvl+VpGAqeTE+4xdyKOmuRd4zwjlUYVppeIBZwGeNAK16kZnrztR+4eQmnzUqPZVybBhEBdKP9weqWHSUg==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.7.8",
|
||||
"@malio/layer-ui": "^1.7.10",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -41,6 +41,22 @@ describe('useFormErrors', () => {
|
||||
expect(hasErrors.value).toBe(true)
|
||||
})
|
||||
|
||||
it('setServerErrors surcharge un message technique (erreur de type) par la cle i18n', () => {
|
||||
const { errors, setServerErrors } = useFormErrors()
|
||||
const mapped = setServerErrors({
|
||||
violations: [
|
||||
// Code Symfony Type::INVALID_TYPE_ERROR (date non parsable) : surcharge.
|
||||
{ propertyPath: 'foundedAt', message: 'Cette valeur doit être de type DateTimeImmutable|null.', code: 'ba785a8c-82cb-4283-967c-3cf342181b40' },
|
||||
// Violation metier classique : message back conserve.
|
||||
{ propertyPath: 'companyName', message: 'Obligatoire.', code: 'c1051bb4-d103-4f74-8988-acbcafc7fdc3' },
|
||||
],
|
||||
})
|
||||
expect(mapped).toBe(true)
|
||||
// Stub i18n -> renvoie la cle telle quelle.
|
||||
expect(errors.foundedAt).toBe('errors.validation.invalidDate')
|
||||
expect(errors.companyName).toBe('Obligatoire.')
|
||||
})
|
||||
|
||||
it('setServerErrors retourne false et ne touche rien sans violation', () => {
|
||||
const { errors, setServerErrors } = useFormErrors()
|
||||
expect(setServerErrors({})).toBe(false)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* appel par ligne), utiliser directement `mapViolationsToRecord` par ligne.
|
||||
*/
|
||||
import { computed, reactive } from 'vue'
|
||||
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
||||
import { extractApiErrorMessage, extractApiViolations, resolveViolationMessage } from '~/shared/utils/api'
|
||||
|
||||
/**
|
||||
* Erreur HTTP capturee par ofetch. On n'expose que les champs lus ici (status
|
||||
@@ -69,13 +69,16 @@ export function useFormErrors() {
|
||||
* violation exploitable).
|
||||
*/
|
||||
function setServerErrors(data: unknown): boolean {
|
||||
const mapped = mapViolationsToRecord(data)
|
||||
const keys = Object.keys(mapped)
|
||||
if (keys.length === 0) return false
|
||||
for (const key of keys) {
|
||||
errors[key] = mapped[key]
|
||||
const violations = extractApiViolations(data)
|
||||
let mapped = false
|
||||
for (const v of violations) {
|
||||
if (!v.propertyPath) continue
|
||||
// Message back tel quel, sauf code surcharge par une cle i18n (ex.
|
||||
// erreur de type sur une date non parsable -> « Date invalide »).
|
||||
errors[v.propertyPath] = resolveViolationMessage(v, t)
|
||||
mapped = true
|
||||
}
|
||||
return true
|
||||
return mapped
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mapViolationsToRecord } from '../api'
|
||||
import { mapViolationsToRecord, resolveViolationMessage } from '../api'
|
||||
|
||||
/**
|
||||
* Tests de `mapViolationsToRecord` — fondation du mapping erreur→champ des
|
||||
@@ -56,3 +56,30 @@ describe('mapViolationsToRecord', () => {
|
||||
expect(mapViolationsToRecord(data)).toEqual({ name: 'Second message.' })
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests de `resolveViolationMessage` — surcharge i18n d'un message back par code
|
||||
* de violation. Le back peut renvoyer un message technique (erreur de type sur
|
||||
* une date non parsable) : on le remplace via le `code` Symfony (stable) par une
|
||||
* cle i18n, sans toucher au back. Le `t` ici renvoie la cle telle quelle.
|
||||
*/
|
||||
describe('resolveViolationMessage', () => {
|
||||
const t = (key: string) => key
|
||||
// Code Symfony Constraints\Type::INVALID_TYPE_ERROR (fige).
|
||||
const TYPE_ERROR = 'ba785a8c-82cb-4283-967c-3cf342181b40'
|
||||
|
||||
it('surcharge le message technique d\'une erreur de type par la cle i18n', () => {
|
||||
const v = { propertyPath: 'foundedAt', message: 'Cette valeur doit être de type DateTimeImmutable|null.', code: TYPE_ERROR }
|
||||
expect(resolveViolationMessage(v, t)).toBe('errors.validation.invalidDate')
|
||||
})
|
||||
|
||||
it('renvoie le message back tel quel quand le code n\'est pas surcharge', () => {
|
||||
const v = { propertyPath: 'companyName', message: 'Le nom est obligatoire.', code: 'c1051bb4-d103-4f74-8988-acbcafc7fdc3' }
|
||||
expect(resolveViolationMessage(v, t)).toBe('Le nom est obligatoire.')
|
||||
})
|
||||
|
||||
it('renvoie le message back tel quel quand il n\'y a pas de code', () => {
|
||||
const v = { propertyPath: 'siren', message: 'SIREN deja utilise.', code: '' }
|
||||
expect(resolveViolationMessage(v, t)).toBe('SIREN deja utilise.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,11 +34,15 @@ export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
||||
|
||||
/**
|
||||
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
|
||||
* pointe le champ concerne, `message` est le libelle a afficher.
|
||||
* pointe le champ concerne, `message` est le libelle a afficher, `code` est le
|
||||
* code de contrainte Symfony (UUID stable, independant de la langue) — il sert
|
||||
* a surcharger un message back technique par une cle i18n (cf.
|
||||
* `VIOLATION_MESSAGE_I18N` / `resolveViolationMessage`).
|
||||
*/
|
||||
export interface ApiViolation {
|
||||
propertyPath: string
|
||||
message: string
|
||||
code: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,6 +65,7 @@ export function extractApiViolations(data: unknown): ApiViolation[] {
|
||||
out.push({
|
||||
propertyPath: String(obj.propertyPath ?? ''),
|
||||
message: String(obj.message ?? ''),
|
||||
code: String(obj.code ?? ''),
|
||||
})
|
||||
}
|
||||
return out
|
||||
@@ -85,6 +90,45 @@ export function mapViolationsToRecord(data: unknown): Record<string, string> {
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Surcharge i18n d'un message back par CODE de violation.
|
||||
*
|
||||
* La plupart des contraintes back portent deja un message FR explicite (ex.
|
||||
* `#[Assert\NotBlank(message: '...')]`) : on l'affiche tel quel. Mais certaines
|
||||
* 422 portent un message TECHNIQUE non montrable a l'utilisateur — typiquement
|
||||
* l'erreur de TYPE renvoyee par API Platform quand le back ne peut pas
|
||||
* denormaliser la valeur (date non parsable envoyee sur un champ
|
||||
* `DateTimeImmutable` : « Cette valeur doit être de type DateTimeImmutable|null. »,
|
||||
* voire en anglais selon la negociation de langue).
|
||||
*
|
||||
* Plutot que de traduire/maquiller cote back, on surcharge ces messages cote
|
||||
* front via leur `code` de violation. Ce code est un UUID Symfony FIGE (contrat
|
||||
* de compatibilite : il ne change pas entre versions), donc bien plus robuste
|
||||
* qu'un match sur le texte du message (qui depend de la langue). La table
|
||||
* associe un code -> une cle i18n ; `resolveViolationMessage` l'applique.
|
||||
*
|
||||
* Limite a connaitre : le code de type-error est GENERIQUE (toute valeur de
|
||||
* mauvais type). Dans nos formulaires, seul un champ date saisi en texte libre
|
||||
* (MalioDate, qui forwarde la saisie brute invalide) le declenche, d'ou le
|
||||
* libelle « Date invalide ». Si un autre champ typé en saisie libre apparait,
|
||||
* affiner la resolution via `propertyPath` plutot que par code seul.
|
||||
*/
|
||||
export const VIOLATION_MESSAGE_I18N: Record<string, string> = {
|
||||
// Symfony `Constraints\Type::INVALID_TYPE_ERROR` — valeur de mauvais type.
|
||||
'ba785a8c-82cb-4283-967c-3cf342181b40': 'errors.validation.invalidDate',
|
||||
}
|
||||
|
||||
/**
|
||||
* Resout le message a afficher pour une violation : si son `code` est surcharge
|
||||
* par `VIOLATION_MESSAGE_I18N`, renvoie la traduction de la cle associee ;
|
||||
* sinon, le message back tel quel (cas nominal). `t` est passe par l'appelant
|
||||
* (les utils sont purs, sans acces a useI18n).
|
||||
*/
|
||||
export function resolveViolationMessage(v: ApiViolation, t: (key: string) => string): string {
|
||||
const i18nKey = VIOLATION_MESSAGE_I18N[v.code]
|
||||
return i18nKey ? t(i18nKey) : v.message
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON
|
||||
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
|
||||
|
||||
@@ -84,6 +84,17 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
'commercial.suppliers.accounting.view',
|
||||
'commercial.suppliers.accounting.manage',
|
||||
'commercial.suppliers.archive',
|
||||
// Technique — Repertoire prestataires (M3, ERP-138). Meme logique que
|
||||
// clients/fournisseurs : mappe sur le persona "tout", pas de nouveau
|
||||
// persona (regle ABSOLUE n°7). user-full porte deja sites.bypass_scope,
|
||||
// donc il voit les prestataires de tous les sites (M3 § 2.13).
|
||||
// technique.providers.view n'ajoute pas de lien dans la section
|
||||
// Administration, donc expectedAdminLinks reste inchange.
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
'technique.providers.archive',
|
||||
],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||
},
|
||||
|
||||
@@ -250,6 +250,14 @@ sync-permissions:
|
||||
seed-rbac:
|
||||
$(SYMFONY_CONSOLE) --no-interaction app:seed-rbac
|
||||
|
||||
# Synchronise le referentiel des transporteurs QUALIMAT (ERP-39) : upsert sur
|
||||
# le SIRET + soft-delete des absents + journal. Idempotent (refresh complet),
|
||||
# prevu pour un cron quotidien.
|
||||
# Options : --dry-run (analyse sans ecriture), --file=<chemin.json> (source
|
||||
# locale au lieu de l'API), --ppp=<n> (taille de page API, defaut 10000).
|
||||
qualimat-sync:
|
||||
$(SYMFONY_CONSOLE) --no-interaction app:qualimat:sync
|
||||
|
||||
# Attention, supprime votre bdd local
|
||||
db-reset:
|
||||
$(DOCKER_COMPOSE) down -v
|
||||
|
||||
@@ -252,7 +252,7 @@ final class Version20260612100000 extends AbstractMigration
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_provider_contact_name
|
||||
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL),
|
||||
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL),
|
||||
CONSTRAINT fk_provider_contact_provider
|
||||
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_contact_created_by
|
||||
@@ -263,12 +263,12 @@ final class Version20260612100000 extends AbstractMigration
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_provider_contact_provider ON provider_contact (provider_id)');
|
||||
|
||||
$this->comment('provider_contact', '_table', 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/telephone/email (RG-3.04, chk_provider_contact_name).');
|
||||
$this->comment('provider_contact', '_table', 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/fonction/telephone/email (RG-3.04, chk_provider_contact_name).');
|
||||
$this->comment('provider_contact', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('provider_contact', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.');
|
||||
$this->comment('provider_contact', 'first_name', 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
|
||||
$this->comment('provider_contact', 'last_name', 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
|
||||
$this->comment('provider_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).');
|
||||
$this->comment('provider_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
|
||||
$this->comment('provider_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (normalisation serveur).');
|
||||
$this->comment('provider_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).');
|
||||
$this->comment('provider_contact', 'email', 'Email du contact (lowercase serveur).');
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* RG-3.04 (correctif) — aligne la regle de validite d'un contact prestataire sur
|
||||
* le M1/M2 : au moins le PRENOM OU le NOM (et non plus « un champ quelconque parmi
|
||||
* prenom/nom/fonction/telephone/email »). Remplace le CHECK chk_provider_contact_name
|
||||
* et met a jour les commentaires de colonnes. La garde applicative
|
||||
* (ProviderContactProcessor::validateName) est alignee dans le meme commit.
|
||||
*
|
||||
* Placee au namespace racine DoctrineMigrations (et non en modulaire Technique) :
|
||||
* elle ALTERE une table creee par une migration racine (Version20260612100000) ;
|
||||
* le tri par version au sein du meme namespace garantit qu'elle joue APRES l'init
|
||||
* (cf. CLAUDE.md regle 11 — le tri cross-namespace casserait l'ordre sur base vide).
|
||||
*/
|
||||
final class Version20260615120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'RG-3.04 : contact prestataire valide si prenom OU nom (alignement M1/M2) — CHECK chk_provider_contact_name.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name');
|
||||
$this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)');
|
||||
|
||||
$this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).$_$');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name');
|
||||
$this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL)');
|
||||
|
||||
$this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/fonction/telephone/email (RG-3.04, chk_provider_contact_name).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
|
||||
}
|
||||
}
|
||||
@@ -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])]
|
||||
|
||||
@@ -22,8 +22,10 @@ use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Context;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
@@ -94,6 +96,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
security: "is_granted('commercial.clients.manage')",
|
||||
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
|
||||
denormalizationContext: ['groups' => ['client:write:main']],
|
||||
// Une valeur de mauvais type (ex. date non parsable sur foundedAt)
|
||||
// doit produire un 422 porte sur le champ (violations[].propertyPath,
|
||||
// mappable inline par useFormErrors) plutot qu'un 400 generique non
|
||||
// exploitable. Le front (MalioDate, MUI-44) forwarde la saisie brute
|
||||
// invalide : le back reste la couche autoritaire du format (ERP-101).
|
||||
collectDenormalizationErrors: true,
|
||||
processor: ClientProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
@@ -117,6 +125,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
'client:write:accounting',
|
||||
'client:write:archive',
|
||||
]],
|
||||
// Cf. Post : date non parsable (foundedAt) -> 422 porte sur le champ
|
||||
// au lieu d'un 400 generique. Indispensable au mapping inline du
|
||||
// front (MalioDate MUI-44 forwarde la saisie brute invalide).
|
||||
collectDenormalizationErrors: true,
|
||||
provider: ClientProvider::class,
|
||||
processor: ClientProcessor::class,
|
||||
),
|
||||
@@ -206,6 +218,13 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
// Format d'ENTREE strict ISO `Y-m-d` (le `!` remet l'heure a 00:00:00). Sans
|
||||
// ce format, PHP DateTime accepte des formes ambigues : « 12/25/2026 » (que
|
||||
// le front MalioDate juge invalide en JJ/MM/AAAA) serait sinon interprete en
|
||||
// M/J/AAAA -> 25 decembre 2026, et accepte a tort. Avec le format, toute
|
||||
// saisie brute non-ISO (forwardee par MalioDate sur date invalide) echoue la
|
||||
// denormalisation -> 422 sur foundedAt (cf. collectDenormalizationErrors).
|
||||
#[Context(denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
|
||||
private ?DateTimeImmutable $foundedAt = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
|
||||
@@ -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])]
|
||||
|
||||
@@ -22,8 +22,10 @@ use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Context;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
@@ -94,6 +96,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
security: "is_granted('commercial.suppliers.manage')",
|
||||
normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']],
|
||||
denormalizationContext: ['groups' => ['supplier:write:main']],
|
||||
// Une valeur de mauvais type (ex. date non parsable sur foundedAt)
|
||||
// doit produire un 422 porte sur le champ (violations[].propertyPath,
|
||||
// mappable inline par useFormErrors) plutot qu'un 400 generique. Le
|
||||
// front (MalioDate, MUI-44) forwarde la saisie brute invalide : le
|
||||
// back reste la couche autoritaire du format (ERP-101). Cf. Client.
|
||||
collectDenormalizationErrors: true,
|
||||
processor: SupplierProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
@@ -113,6 +121,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
'supplier:write:accounting',
|
||||
'supplier:write:archive',
|
||||
]],
|
||||
// Cf. Post : date non parsable (foundedAt) -> 422 porte sur le champ
|
||||
// au lieu d'un 400 generique. Indispensable au mapping inline du
|
||||
// front (MalioDate MUI-44 forwarde la saisie brute invalide).
|
||||
collectDenormalizationErrors: true,
|
||||
provider: SupplierProvider::class,
|
||||
processor: SupplierProcessor::class,
|
||||
),
|
||||
@@ -187,6 +199,11 @@ class Supplier implements TimestampableInterface, BlamableInterface
|
||||
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
#[Groups(['supplier:read', 'supplier:write:information'])]
|
||||
// Format d'ENTREE strict ISO `Y-m-d` : sans lui, PHP DateTime accepte des
|
||||
// formes ambigues (« 12/25/2026 », jugee invalide par MalioDate en JJ/MM/AAAA,
|
||||
// serait lue en M/J -> 25 decembre et acceptee a tort). Avec le format, toute
|
||||
// saisie brute non-ISO echoue -> 422 sur foundedAt. Cf. Client.
|
||||
#[Context(denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
|
||||
private ?DateTimeImmutable $foundedAt = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
|
||||
@@ -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])]
|
||||
|
||||
@@ -50,11 +50,19 @@ final class RbacSeeder
|
||||
/**
|
||||
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
|
||||
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
|
||||
* attacher (vide pour usine : aucun acces ; admin n'apparait pas car il
|
||||
* bypass tout via isAdmin ; `commercial.clients.archive` et
|
||||
* `commercial.suppliers.archive` ne sont attaches a aucun role metier —
|
||||
* attacher (admin n'apparait pas car il bypass tout via isAdmin ;
|
||||
* `commercial.clients.archive`, `commercial.suppliers.archive` et
|
||||
* `technique.providers.archive` ne sont attaches a aucun role metier —
|
||||
* admin seul).
|
||||
*
|
||||
* Cloisonnement par site des prestataires (M3 § 2.13) : la permission
|
||||
* `sites.bypass_scope` est attribuee par defaut a Bureau / Compta /
|
||||
* Commerciale (ils voient « Tout », d'apres le docx) ; Usine ne l'a PAS et
|
||||
* reste cloisonnee a son site courant. Admin a le bypass total via isAdmin.
|
||||
* C'est un cloisonnement pilote par user/permission, pas par code de role :
|
||||
* pour cloisonner Bureau/Commerciale, il suffit de retirer la permission
|
||||
* ici, aucun autre code a changer.
|
||||
*
|
||||
* @var array<string, array{label: string, permissions: list<string>}>
|
||||
*/
|
||||
private const array MATRIX = [
|
||||
@@ -66,6 +74,11 @@ final class RbacSeeder
|
||||
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
|
||||
'commercial.suppliers.view',
|
||||
'commercial.suppliers.manage',
|
||||
// Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite).
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
|
||||
'sites.bypass_scope',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
'catalog.categories.read_ref',
|
||||
'sites.read_ref',
|
||||
@@ -82,6 +95,13 @@ final class RbacSeeder
|
||||
'commercial.suppliers.view',
|
||||
'commercial.suppliers.accounting.view',
|
||||
'commercial.suppliers.accounting.manage',
|
||||
// Prestataires (M3 § 2.9, ERP-138) : view + onglet Comptabilite uniquement
|
||||
// (pas de manage global -> ne peut pas creer un prestataire).
|
||||
'technique.providers.view',
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
|
||||
'sites.bypass_scope',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
'catalog.categories.read_ref',
|
||||
'sites.read_ref',
|
||||
@@ -96,14 +116,25 @@ final class RbacSeeder
|
||||
// (onglet Comptabilite masque/filtre pour la Commerciale).
|
||||
'commercial.suppliers.view',
|
||||
'commercial.suppliers.manage',
|
||||
// Prestataires (M3 § 2.9, ERP-138) : view + manage, sans accounting
|
||||
// (onglet Comptabilite masque/filtre pour la Commerciale).
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
|
||||
'sites.bypass_scope',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
'catalog.categories.read_ref',
|
||||
'sites.read_ref',
|
||||
],
|
||||
],
|
||||
self::ROLE_USINE => [
|
||||
'label' => 'Usine',
|
||||
'permissions' => [],
|
||||
'label' => 'Usine',
|
||||
// Prestataires (M3 § 2.9 + § 2.13, ERP-138) : view en lecture seule,
|
||||
// SANS `sites.bypass_scope` -> cloisonne aux prestataires de son site
|
||||
// courant. Aucun autre acces metier.
|
||||
'permissions' => [
|
||||
'technique.providers.view',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -203,6 +203,15 @@ final class SeedE2ECommand extends Command
|
||||
'commercial.suppliers.accounting.view',
|
||||
'commercial.suppliers.accounting.manage',
|
||||
'commercial.suppliers.archive',
|
||||
// Technique — Repertoire prestataires (M3, ERP-138). Meme
|
||||
// logique : mappe sur le persona "tout". user-full porte deja
|
||||
// sites.bypass_scope -> voit les prestataires de tous les
|
||||
// sites (M3 § 2.13). Miroir de personas.ts.
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
'technique.providers.archive',
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -65,6 +65,23 @@ final class ProviderFieldNormalizer
|
||||
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Texte libre simplement trim (ex : jobTitle / Fonction du contact). Pas de
|
||||
* changement de casse — on preserve la saisie. Une chaine vide apres trim
|
||||
* devient null (evite de persister "" et de faire passer a tort le garde-fou
|
||||
* RG-3.04 / le CHECK chk_provider_contact_name sur une Fonction vide).
|
||||
*/
|
||||
public function normalizeText(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Telephone reduit aux chiffres (RG-3.11) : "06.12.34.56.78" ->
|
||||
* "0612345678". Une valeur sans aucun chiffre devient null.
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Application\Validator;
|
||||
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Validator metier (spec-front M3 § Onglet Comptabilite — jumeau de
|
||||
* SupplierAccountingCompletenessValidator M2) : a la soumission complete de
|
||||
* l'onglet Comptabilite, les six champs scalaires obligatoires doivent etre
|
||||
* renseignes (SIREN, Numero de compte, Mode de TVA, N de TVA, Delai de reglement,
|
||||
* Type de reglement). La banque reste conditionnelle (RG-3.07) et les RIB aussi
|
||||
* (RG-3.08) : ils ne sont pas couverts ici (Assert\Callback sur l'entite Provider
|
||||
* — validatePaymentTypeConsistency).
|
||||
*
|
||||
* Parti pris (miroir M1/M2) : colonnes nullable en base + validateur contextuel,
|
||||
* plutot qu'un Assert\NotBlank sur l'entite (qui casserait le POST de l'onglet
|
||||
* principal, lequel n'envoie aucun champ comptable).
|
||||
*
|
||||
* Invoque par le ProviderProcessor uniquement quand le payload porte les six
|
||||
* champs (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ.
|
||||
*
|
||||
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
|
||||
* coherence avec les violations Symfony rendues par API Platform (mapping inline
|
||||
* front via useFormErrors, ERP-101).
|
||||
*/
|
||||
final class ProviderAccountingCompletenessValidator
|
||||
{
|
||||
public function validate(Provider $provider): void
|
||||
{
|
||||
// Map champ -> valeur courante des champs obligatoires de l'onglet.
|
||||
$fields = [
|
||||
'siren' => $provider->getSiren(),
|
||||
'accountNumber' => $provider->getAccountNumber(),
|
||||
'tvaMode' => $provider->getTvaMode(),
|
||||
'nTva' => $provider->getNTva(),
|
||||
'paymentDelay' => $provider->getPaymentDelay(),
|
||||
'paymentType' => $provider->getPaymentType(),
|
||||
];
|
||||
|
||||
$violations = new ConstraintViolationList();
|
||||
|
||||
foreach ($fields as $property => $value) {
|
||||
if ($this->isMissing($value)) {
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Ce champ est obligatoire.',
|
||||
null,
|
||||
[],
|
||||
$provider,
|
||||
$property,
|
||||
$value,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (count($violations) > 0) {
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les
|
||||
* references (TvaMode / PaymentDelay / PaymentType) ne sont manquantes que
|
||||
* lorsqu'elles valent null.
|
||||
*/
|
||||
private function isMissing(mixed $value): bool
|
||||
{
|
||||
if (null === $value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_string($value) && '' === trim($value);
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,12 @@ class Provider implements TimestampableInterface, BlamableInterface
|
||||
*/
|
||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
|
||||
|
||||
/** Code pivot du type de reglement imposant une banque (RG-3.07). */
|
||||
private const string PAYMENT_TYPE_VIREMENT = 'VIREMENT';
|
||||
|
||||
/** Code pivot du type de reglement imposant au moins un RIB (RG-3.08). */
|
||||
private const string PAYMENT_TYPE_LCR = 'LCR';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
@@ -291,6 +297,44 @@ class Provider implements TimestampableInterface, BlamableInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.07 / RG-3.08 : coherence du type de reglement comptable. Comme au M2
|
||||
* (decision figee ERP-89, jumeau Supplier::validatePaymentTypeConsistency),
|
||||
* ces RG inter-champs passent par une contrainte d'entite (Assert\Callback +
|
||||
* ->atPath()) et NON par le ProviderProcessor, afin que chaque 422 porte un
|
||||
* propertyPath exploitable par extractApiViolations (mapping inline sous le
|
||||
* champ, pas un toast — convention ERP-101).
|
||||
* - RG-3.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`.
|
||||
* - RG-3.08 : paymentType = LCR impose au moins un RIB -> violation sur
|
||||
* `paymentType` (les RIB n'ont pas de champ de formulaire ou s'ancrer quand
|
||||
* la liste est vide ; l'erreur s'affiche donc sous le select « Type de
|
||||
* règlement », binde cote front). Le 409 sur DELETE du dernier RIB en LCR est
|
||||
* porte par le ProviderRibProcessor (ERP-135).
|
||||
*
|
||||
* Ces champs vivant dans le groupe d'ecriture comptable (absent du POST, qui
|
||||
* n'expose que provider:write:main), la contrainte ne mord en pratique que sur
|
||||
* le PATCH de l'onglet Comptabilite.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validatePaymentTypeConsistency(ExecutionContextInterface $context): void
|
||||
{
|
||||
$paymentCode = $this->paymentType?->getCode();
|
||||
|
||||
if (self::PAYMENT_TYPE_VIREMENT === $paymentCode && null === $this->bank) {
|
||||
$context->buildViolation('La banque est obligatoire pour le type de règlement Virement.')
|
||||
->atPath('bank')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) {
|
||||
$context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.')
|
||||
->atPath('paymentType')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderAddressProcessor;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
@@ -32,16 +40,64 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
* type PRESTATAIRE attendu (RG-3.09, Assert\Callback validateCategoryType).
|
||||
*
|
||||
* Embarquee sous `provider.addresses` au detail (groupe provider:item:read,
|
||||
* maillon (a)). L'exposition en SOUS-RESSOURCE API est un ticket ulterieur du M3 :
|
||||
* pas d'#[ApiResource] ici.
|
||||
* maillon (a)).
|
||||
*
|
||||
* Sous-ressource API (ERP-135, spec § 4.5) :
|
||||
* - POST /api/providers/{providerId}/addresses : creation rattachee au prestataire
|
||||
* parent (Link toProperty 'provider'), security technique.providers.manage.
|
||||
* - PATCH / DELETE /api/provider_addresses/{id} : security technique.providers.manage.
|
||||
* - GET /api/provider_addresses/{id} : lecture unitaire (security view) — la lecture
|
||||
* courante reste via le parent. Pas de GET collection autonome.
|
||||
* Tout passe par le ProviderAddressProcessor (rattachement parent + cloisonnement
|
||||
* d'ecriture des sites, § 2.13). Les regles RG-3.05/3.06/3.09 sont portees par les
|
||||
* contraintes de l'entite (jouees avant le processor).
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable / Blamable.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('technique.providers.view')",
|
||||
// site:read + category:read : embarquent les Site / Category lies
|
||||
// (maillon (c)) plutot que des IRI nus dans le retour.
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
||||
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/providers/{providerId}/addresses',
|
||||
uriVariables: [
|
||||
'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
|
||||
],
|
||||
// read:false : pas de stade lecture du parent. Le Link toProperty
|
||||
// resoudrait l'enfant (SELECT ProviderAddress ... WHERE provider = :id)
|
||||
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
||||
// manuellement par ProviderAddressProcessor::linkParent (404 si absent).
|
||||
read: false,
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['provider:write:addresses']],
|
||||
processor: ProviderAddressProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['provider:write:addresses']],
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
processor: ProviderAddressProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
processor: ProviderAddressProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'provider_address')]
|
||||
#[ORM\Index(name: 'idx_provider_address_provider', columns: ['provider_id'])]
|
||||
#[Auditable]
|
||||
class ProviderAddress implements TimestampableInterface, BlamableInterface
|
||||
class ProviderAddress implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderContactProcessor;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
@@ -15,24 +23,68 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
/**
|
||||
* Contact d'un prestataire (1:n) — onglet Contacts. Un bloc est valide des qu'au
|
||||
* moins un champ est rempli (RG-3.04) : garantie portee par un CHECK BDD
|
||||
* (chk_provider_contact_name) + le ProviderProcessor (ERP-134) ; l'entite reste
|
||||
* permissive (tous les champs nullable).
|
||||
* (chk_provider_contact_name) + le ProviderContactProcessor (ERP-135) ; l'entite
|
||||
* reste permissive (tous les champs nullable).
|
||||
*
|
||||
* Embarque sous `provider.contacts` au detail (groupe provider:item:read,
|
||||
* maillon (a) du contrat de serialisation). Maximum 2 telephones
|
||||
* (phonePrimary + phoneSecondary).
|
||||
*
|
||||
* L'exposition en SOUS-RESSOURCE API (POST /providers/{id}/contacts, PATCH /
|
||||
* DELETE) est un ticket ulterieur du M3 : pas d'#[ApiResource] ici (l'entite est
|
||||
* pour l'instant uniquement embarquee via le detail du prestataire).
|
||||
* Sous-ressource API (ERP-135, spec § 4.5) :
|
||||
* - POST /api/providers/{providerId}/contacts : creation rattachee au prestataire
|
||||
* parent (Link toProperty 'provider'), security technique.providers.manage.
|
||||
* - PATCH / DELETE /api/provider_contacts/{id} : security technique.providers.manage.
|
||||
* Le DELETE est physique et libre (pas de garde « dernier contact » au M3 —
|
||||
* RG-3.12 front-driven, la collection peut rester vide cote back).
|
||||
* - GET /api/provider_contacts/{id} : lecture unitaire (security view) — la lecture
|
||||
* courante reste via le parent (le prestataire embarque ses contacts). Pas de GET
|
||||
* collection autonome.
|
||||
* Tout passe par le ProviderContactProcessor (normalisation RG-3.11, RG-3.04).
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable / Blamable (pattern Shared standard).
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('technique.providers.view')",
|
||||
normalizationContext: ['groups' => ['provider:item:read']],
|
||||
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/providers/{providerId}/contacts',
|
||||
uriVariables: [
|
||||
'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
|
||||
],
|
||||
// read:false : pas de stade lecture du parent. Le Link toProperty
|
||||
// resoudrait l'enfant (SELECT ProviderContact ... WHERE provider = :id)
|
||||
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
||||
// manuellement par ProviderContactProcessor::linkParent (404 si absent).
|
||||
read: false,
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
normalizationContext: ['groups' => ['provider:item:read']],
|
||||
denormalizationContext: ['groups' => ['provider:write:contacts']],
|
||||
processor: ProviderContactProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
normalizationContext: ['groups' => ['provider:item:read']],
|
||||
denormalizationContext: ['groups' => ['provider:write:contacts']],
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
processor: ProviderContactProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
processor: ProviderContactProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'provider_contact')]
|
||||
#[ORM\Index(name: 'idx_provider_contact_provider', columns: ['provider_id'])]
|
||||
#[Auditable]
|
||||
class ProviderContact implements TimestampableInterface, BlamableInterface
|
||||
class ProviderContact implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Domain\Entity;
|
||||
|
||||
/**
|
||||
* Contrat des sous-ressources d'un prestataire (Contact, Adresse, RIB) : chacune
|
||||
* appartient a un Provider parent. Permet au provider decore
|
||||
* ProviderSubResourceItemProvider d'appliquer le cloisonnement par site du parent
|
||||
* (§ 2.13 / RG-3.17) de maniere uniforme, sans connaitre le type concret.
|
||||
*/
|
||||
interface ProviderOwnedInterface
|
||||
{
|
||||
public function getProvider(): ?Provider;
|
||||
}
|
||||
@@ -4,6 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderRibProcessor;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
@@ -15,7 +23,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
/**
|
||||
* Coordonnees bancaires d'un prestataire (1:n) — onglet Comptabilite. Au moins un
|
||||
* RIB est obligatoire si le type de reglement est LCR (RG-3.08, verifie au
|
||||
* ProviderProcessor : refus du DELETE du dernier RIB sous LCR — ERP-134).
|
||||
* ProviderRibProcessor : refus du DELETE du dernier RIB sous LCR — ERP-135).
|
||||
*
|
||||
* Embarque sous `provider.ribs` UNIQUEMENT si l'user a accounting.view : le
|
||||
* read-group est `provider:read:accounting`, retire du contexte par le
|
||||
@@ -23,19 +31,62 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
* piege n°4 du M1). Aucun #[AuditIgnore] sur iban/bic : l'audit etant admin-only,
|
||||
* la tracabilite RIB est conservee (decision M1 reportee, § 2.7).
|
||||
*
|
||||
* L'exposition en SOUS-RESSOURCE API (POST /providers/{id}/ribs, PATCH / DELETE,
|
||||
* gating accounting.manage) est un ticket ulterieur du M3 : pas d'#[ApiResource]
|
||||
* ici.
|
||||
* Sous-ressource API (ERP-135, spec § 4.5) — gating comptable renforce :
|
||||
* - POST /api/providers/{providerId}/ribs : creation rattachee au prestataire
|
||||
* parent (Link toProperty 'provider'), security technique.providers.accounting.manage.
|
||||
* - PATCH / DELETE /api/provider_ribs/{id} : security technique.providers.accounting.manage.
|
||||
* Le DELETE refuse la suppression du dernier RIB sous LCR (RG-3.08, 409).
|
||||
* - GET /api/provider_ribs/{id} : lecture unitaire, security
|
||||
* technique.providers.accounting.view (donnees bancaires sensibles). Pas de GET
|
||||
* collection autonome.
|
||||
* Tout passe par le ProviderRibProcessor (RG-3.08 sur DELETE).
|
||||
*
|
||||
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony (pas de controle
|
||||
* banque reelle), avec controle croise pays BIC/IBAN (ibanPropertyPath). Audite
|
||||
* (#[Auditable]) + Timestampable / Blamable.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('technique.providers.accounting.view')",
|
||||
normalizationContext: ['groups' => ['provider:read:accounting']],
|
||||
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/providers/{providerId}/ribs',
|
||||
uriVariables: [
|
||||
'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
|
||||
],
|
||||
// read:false : pas de stade lecture du parent. Le Link toProperty
|
||||
// resoudrait l'enfant (SELECT ProviderRib ... WHERE provider = :id) et
|
||||
// casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
||||
// manuellement par ProviderRibProcessor::linkParent (404 si absent).
|
||||
read: false,
|
||||
security: "is_granted('technique.providers.accounting.manage')",
|
||||
normalizationContext: ['groups' => ['provider:read:accounting']],
|
||||
denormalizationContext: ['groups' => ['provider:write:accounting']],
|
||||
processor: ProviderRibProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('technique.providers.accounting.manage')",
|
||||
normalizationContext: ['groups' => ['provider:read:accounting']],
|
||||
denormalizationContext: ['groups' => ['provider:write:accounting']],
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
processor: ProviderRibProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('technique.providers.accounting.manage')",
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
processor: ProviderRibProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'provider_rib')]
|
||||
#[ORM\Index(name: 'idx_provider_rib_provider', columns: ['provider_id'])]
|
||||
#[Auditable]
|
||||
class ProviderRib implements TimestampableInterface, BlamableInterface
|
||||
class ProviderRib implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Entity\ProviderAddress;
|
||||
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use JsonException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Adresse d'un prestataire (M3,
|
||||
* spec-back § 4.5). Jumeau du SupplierAddressProcessor (M2), recentre sur le
|
||||
* perimetre ERP-135, AVEC une garde supplementaire propre au M3 : le
|
||||
* cloisonnement d'ECRITURE des sites de l'adresse (§ 2.13).
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au prestataire parent puis cloisonnement des
|
||||
* sites de l'adresse (RG-3.05 / § 2.13). Les regles de l'onglet Adresse sont
|
||||
* garanties en amont par des contraintes sur l'entite, jouees par API Platform
|
||||
* avant ce processor : RG-3.06 (code postal, Assert\Regex), RG-3.05 (>= 1 site,
|
||||
* Assert\Count), RG-3.09 (categorie de type PRESTATAIRE, Assert\Callback
|
||||
* ProviderAddress::validateCategoryType).
|
||||
* - DELETE : aucune regle metier specifique (suppression physique directe).
|
||||
*
|
||||
* La security de l'operation (technique.providers.manage) est appliquee par API
|
||||
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut.
|
||||
*
|
||||
* @implements ProcessorInterface<ProviderAddress, null|ProviderAddress>
|
||||
*/
|
||||
final class ProviderAddressProcessor implements ProcessorInterface
|
||||
{
|
||||
private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope';
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly Security $security,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ProviderSiteScopeChecker $scopeChecker,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof ProviderAddress) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->guardSiteScope($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache l'adresse au prestataire parent de la sous-ressource POST
|
||||
* (/providers/{providerId}/addresses) : la relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
|
||||
*/
|
||||
private function linkParent(ProviderAddress $address, array $uriVariables): void
|
||||
{
|
||||
if (null !== $address->getProvider()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$providerId = $uriVariables['providerId'] ?? null;
|
||||
if (null === $providerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$provider = $providerId instanceof Provider
|
||||
? $providerId
|
||||
: $this->em->getRepository(Provider::class)->find($providerId);
|
||||
|
||||
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
|
||||
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
|
||||
// contrainte provider_id NOT NULL).
|
||||
if (!$provider instanceof Provider) {
|
||||
throw new NotFoundHttpException('Prestataire introuvable.');
|
||||
}
|
||||
|
||||
// Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
|
||||
// sous-ressource sur un prestataire hors du perimetre de l'user -> 404
|
||||
// (anti-enumeration). Distinct du guardSiteScope ci-dessous, qui cloisonne
|
||||
// les sites ATTACHES a l'adresse (et non l'acces au prestataire parent).
|
||||
$this->scopeChecker->assertInScope($provider);
|
||||
|
||||
$address->setProvider($provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.05 / § 2.13 (cloisonnement d'ECRITURE — decision Matthieu 11/06) : un
|
||||
* user SANS `sites.bypass_scope` ne peut attacher a CHAQUE adresse que des
|
||||
* sites figurant dans ses propres `user_site`. Tout site hors perimetre -> 422
|
||||
* sur `sites` (propertyPath consommable inline, convention ERP-101). Un user
|
||||
* `bypass_scope` (Admin) peut attacher n'importe quel site. Miroir de
|
||||
* ProviderProcessor::guardSiteScope, applique ici a la sous-ressource adresse.
|
||||
*
|
||||
* Ne joue que si `sites` est effectivement soumis : POST (entite non geree,
|
||||
* sites obligatoires RG-3.05) ou PATCH portant la cle `sites`. Un PATCH qui ne
|
||||
* touche pas aux sites n'est pas re-valide (les sites ont ete cloisonnes a leur
|
||||
* pose). La validation porte sur l'ETAT RESULTANT (address.getSites()).
|
||||
*/
|
||||
private function guardSiteScope(ProviderAddress $address): void
|
||||
{
|
||||
if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// sites non soumis sur un PATCH : rien a cloisonner.
|
||||
if ($this->em->contains($address) && !in_array('sites', $this->payloadKeys(), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$allowedSiteIds = $this->currentUserSiteIds();
|
||||
|
||||
foreach ($address->getSites() as $site) {
|
||||
if (!$site instanceof SiteInterface) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($site->getId(), $allowedSiteIds, true)) {
|
||||
$this->throwSitesViolation($address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifiants des sites rattaches a l'utilisateur courant (`user_site`).
|
||||
* Vide si pas d'user authentifie (cas defensif : la security d'operation
|
||||
* garantit deja l'authentification).
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function currentUserSiteIds(): array
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
foreach ($user->getSites() as $site) {
|
||||
if ($site instanceof SiteInterface && null !== $site->getId()) {
|
||||
$ids[] = $site->getId();
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cles de premier niveau effectivement envoyees par le client (payload JSON
|
||||
* brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies.
|
||||
* Corps vide ou JSON invalide -> aucune cle.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function payloadKeys(): array
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
if (null === $request) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = $request->getContent();
|
||||
if ('' === $content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Leve une 422 portant une violation unique sur `sites` — meme rendu Hydra que
|
||||
* les contraintes Symfony, consommable inline par extractApiViolations (ERP-101).
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
private function throwSitesViolation(ProviderAddress $address): void
|
||||
{
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Vous ne pouvez rattacher que des sites auxquels vous avez accès.',
|
||||
null,
|
||||
[],
|
||||
$address,
|
||||
'sites',
|
||||
null,
|
||||
));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Entity\ProviderContact;
|
||||
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Contact d'un prestataire (M3,
|
||||
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
|
||||
* perimetre ERP-135.
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au prestataire parent, normalisation serveur
|
||||
* (RG-3.11 : prenom/nom Title Case, telephones reduits aux chiffres, email
|
||||
* lowercase) via le ProviderFieldNormalizer partage, puis validation RG-3.04
|
||||
* (au moins un champ parmi prenom / nom / telephone principal / email) avant
|
||||
* persistance.
|
||||
* - DELETE : aucune garde « dernier contact » au M3 — la collection peut rester
|
||||
* vide cote back (RG-3.12 front-driven, spec § 4.5). Suppression physique directe.
|
||||
*
|
||||
* La security de l'operation (technique.providers.manage) est appliquee par API
|
||||
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut
|
||||
* (Assert\Email, Assert\Length...).
|
||||
*
|
||||
* @implements ProcessorInterface<ProviderContact, null|ProviderContact>
|
||||
*/
|
||||
final class ProviderContactProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly ProviderFieldNormalizer $normalizer,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ProviderSiteScopeChecker $scopeChecker,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof ProviderContact) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->normalize($data);
|
||||
$this->validateName($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache le contact au prestataire parent de la sous-ressource POST
|
||||
* (/providers/{providerId}/contacts). La relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une operation d'ecriture : on resout le
|
||||
* parent depuis l'uri variable. Sur PATCH (entite existante), le prestataire
|
||||
* est deja present -> no-op.
|
||||
*/
|
||||
private function linkParent(ProviderContact $contact, array $uriVariables): void
|
||||
{
|
||||
if (null !== $contact->getProvider()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$providerId = $uriVariables['providerId'] ?? null;
|
||||
if (null === $providerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$provider = $providerId instanceof Provider
|
||||
? $providerId
|
||||
: $this->em->getRepository(Provider::class)->find($providerId);
|
||||
|
||||
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
|
||||
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
|
||||
// contrainte provider_id NOT NULL).
|
||||
if (!$provider instanceof Provider) {
|
||||
throw new NotFoundHttpException('Prestataire introuvable.');
|
||||
}
|
||||
|
||||
// Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
|
||||
// sous-ressource sur un prestataire hors du perimetre de l'user -> 404
|
||||
// (anti-enumeration, coherent avec le detail Provider garde en 404).
|
||||
$this->scopeChecker->assertInScope($provider);
|
||||
|
||||
$contact->setProvider($provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisation serveur (RG-3.11). Toutes les methodes du normalizer sont
|
||||
* null-safe : une chaine vide apres trim devient null.
|
||||
*/
|
||||
private function normalize(ProviderContact $contact): void
|
||||
{
|
||||
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
|
||||
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
|
||||
$contact->setJobTitle($this->normalizer->normalizeText($contact->getJobTitle()));
|
||||
$contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary()));
|
||||
$contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary()));
|
||||
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.04 : un bloc Contact exige au moins le prenom OU le nom (aligne sur le
|
||||
* M1/M2 — un contact se materialise par son nom ; fonction / telephone / email
|
||||
* seuls ne suffisent pas). Double garde avec le CHECK BDD chk_provider_contact_name
|
||||
* — leve une 422 propre rattachee au champ `firstName` plutot qu'une 500 SQL.
|
||||
* Joue apres normalisation (les chaines vides sont deja ramenees a null).
|
||||
*/
|
||||
private function validateName(ProviderContact $contact): void
|
||||
{
|
||||
if (null === $contact->getFirstName() && null === $contact->getLastName()) {
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Le prénom ou le nom du contact est obligatoire.',
|
||||
null,
|
||||
[],
|
||||
$contact,
|
||||
'firstName',
|
||||
null,
|
||||
));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
-6
@@ -9,6 +9,7 @@ use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
||||
use App\Module\Technique\Application\Validator\ProviderAccountingCompletenessValidator;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use DateTimeImmutable;
|
||||
@@ -52,12 +53,13 @@ use Symfony\Component\Validator\ConstraintViolationList;
|
||||
* collisions d'unicite en 409 (RG-3.10 doublon de nom ; RG-3.14 conflit de
|
||||
* restauration).
|
||||
*
|
||||
* La RG-3.09 (categorie de type PRESTATAIRE) est portee par un Assert\Callback +
|
||||
* ->atPath() sur l'entite Provider (joue par API Platform AVANT ce processor),
|
||||
* pour que la 422 porte un propertyPath consommable par extractApiViolations
|
||||
* (mapping inline, pas un toast — convention ERP-101). Les RG-3.07 (Virement ->
|
||||
* banque) et RG-3.08 (LCR -> RIB) relevent de l'onglet Comptabilite / sous-ressource
|
||||
* RIB (ticket dedie) et ne sont pas portees ici.
|
||||
* Les RG inter-champs RG-3.07 (Virement -> banque), RG-3.08 (LCR -> >= 1 RIB) et
|
||||
* RG-3.09 (categorie de type PRESTATAIRE) sont portees par des Assert\Callback +
|
||||
* ->atPath() sur les entites Provider / ProviderAddress (jouees par API Platform
|
||||
* AVANT ce processor), pour que chaque 422 porte un propertyPath consommable par
|
||||
* extractApiViolations (mapping inline, pas un toast — convention ERP-101). Le 409
|
||||
* sur DELETE du dernier RIB en LCR (volet ecriture de RG-3.08) est porte par le
|
||||
* ProviderRibProcessor (ERP-135).
|
||||
*
|
||||
* @implements ProcessorInterface<Provider, Provider>
|
||||
*/
|
||||
@@ -74,6 +76,15 @@ final class ProviderProcessor implements ProcessorInterface
|
||||
'paymentType', 'bank',
|
||||
];
|
||||
|
||||
/**
|
||||
* Champs comptables obligatoires a la validation complete de l'onglet
|
||||
* (spec-front M3 § Onglet Comptabilite — miroir M1/M2). bank est exclu :
|
||||
* conditionnel (RG-3.07).
|
||||
*/
|
||||
private const array ACCOUNTING_REQUIRED_FIELDS = [
|
||||
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType',
|
||||
];
|
||||
|
||||
/** Champ d'archivage (groupe provider:write:archive). */
|
||||
private const string ARCHIVE_FIELD = 'isArchived';
|
||||
|
||||
@@ -101,6 +112,7 @@ final class ProviderProcessor implements ProcessorInterface
|
||||
private readonly Security $security,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ProviderAccountingCompletenessValidator $accountingValidator,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
@@ -127,6 +139,10 @@ final class ProviderProcessor implements ProcessorInterface
|
||||
// deux cotes (l'etat persiste l'a deja ete).
|
||||
$this->guardManage($data);
|
||||
|
||||
// Completude de l'onglet Comptabilite (apres normalize : les chaines vides
|
||||
// sont deja ramenees a null). Joue uniquement sur une soumission d'onglet.
|
||||
$this->validateAccountingCompleteness($data);
|
||||
|
||||
try {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
@@ -495,6 +511,21 @@ final class ProviderProcessor implements ProcessorInterface
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
/**
|
||||
* Completude de l'onglet Comptabilite (miroir SupplierProcessor) : ne se
|
||||
* declenche que si TOUS les champs requis sont presents dans le payload
|
||||
* (= soumission d'onglet, pas un PATCH partiel cible). Delegue au validateur
|
||||
* qui leve une 422 listant chaque champ manquant (mapping inline ERP-101).
|
||||
*/
|
||||
private function validateAccountingCompleteness(Provider $data): void
|
||||
{
|
||||
if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->accountingValidator->validate($data);
|
||||
}
|
||||
|
||||
private function payloadKeys(): array
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Entity\ProviderRib;
|
||||
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource RIB d'un prestataire (M3, spec-back
|
||||
* § 4.5). Jumeau du SupplierRibProcessor (M2), recentre sur le perimetre ERP-135.
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au prestataire parent. Aucune normalisation
|
||||
* specifique ; la validite de l'IBAN et du BIC est garantie par Assert\Iban /
|
||||
* Assert\Bic sur l'entite (jouees en amont par API Platform). Aucun
|
||||
* #[AuditIgnore] sur iban/bic : la tracabilite comptable est volontaire
|
||||
* (decision M1/M2 reportee, spec § 2.7).
|
||||
* - DELETE : RG-3.08 — si le prestataire est en reglement LCR, la suppression de
|
||||
* son DERNIER RIB est refusee (409), car LCR exige au moins un RIB.
|
||||
*
|
||||
* La security de l'operation (technique.providers.accounting.manage) est appliquee
|
||||
* par API Platform en amont : un utilisateur sans cette permission recoit 403 sur
|
||||
* POST/PATCH/DELETE avant d'atteindre ce processor — c'est le niveau de gating
|
||||
* renforce des donnees bancaires (distinct de manage, spec § 4.5).
|
||||
*
|
||||
* @implements ProcessorInterface<ProviderRib, null|ProviderRib>
|
||||
*/
|
||||
final class ProviderRibProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ProviderSiteScopeChecker $scopeChecker,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof ProviderRib) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
$this->guardLastRibDeletionUnderLcr($data);
|
||||
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache le RIB au prestataire parent de la sous-ressource POST
|
||||
* (/providers/{providerId}/ribs) : la relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
|
||||
*/
|
||||
private function linkParent(ProviderRib $rib, array $uriVariables): void
|
||||
{
|
||||
if (null !== $rib->getProvider()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$providerId = $uriVariables['providerId'] ?? null;
|
||||
if (null === $providerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$provider = $providerId instanceof Provider
|
||||
? $providerId
|
||||
: $this->em->getRepository(Provider::class)->find($providerId);
|
||||
|
||||
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
|
||||
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
|
||||
// contrainte provider_id NOT NULL).
|
||||
if (!$provider instanceof Provider) {
|
||||
throw new NotFoundHttpException('Prestataire introuvable.');
|
||||
}
|
||||
|
||||
// Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
|
||||
// sous-ressource sur un prestataire hors du perimetre de l'user -> 404
|
||||
// (anti-enumeration, coherent avec le detail Provider garde en 404).
|
||||
$this->scopeChecker->assertInScope($provider);
|
||||
|
||||
$rib->setProvider($provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.08 : un prestataire dont le type de reglement est LCR doit conserver au
|
||||
* moins un RIB. La collection inclut le RIB en cours de suppression : un
|
||||
* effectif <= 1 signifie qu'il ne resterait aucun RIB -> 409. Pour tout autre
|
||||
* type de reglement, les RIBs sont optionnels (suppression libre).
|
||||
*/
|
||||
private function guardLastRibDeletionUnderLcr(ProviderRib $rib): void
|
||||
{
|
||||
$provider = $rib->getProvider();
|
||||
if (null === $provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('LCR' === $provider->getPaymentType()?->getCode() && $provider->getRibs()->count() <= 1) {
|
||||
throw new ConflictHttpException(
|
||||
'Impossible de supprimer le dernier RIB : le type de règlement LCR exige au moins un RIB.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-42
@@ -9,12 +9,10 @@ use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
@@ -64,12 +62,11 @@ final class ProviderProvider implements ProviderInterface
|
||||
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
|
||||
private readonly ProviderRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
private readonly Security $security,
|
||||
// Outillage site-aware sanctionne (site-aware.md § 6.2 : « injecter
|
||||
// CurrentSiteProvider dans le service et ajouter la clause WHERE
|
||||
// manuellement » pour les cas multi-site non couverts par
|
||||
// SiteScopedQueryExtension). Type-hint sur l'interface pour le mock test.
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
// Decision de cloisonnement par site centralisee (site-aware.md § 6.2) :
|
||||
// source UNIQUE partagee avec le provider decore des sous-ressources
|
||||
// (ProviderSubResourceItemProvider) et les processors d'ecriture, pour
|
||||
// eviter tout drift entre ces points d'application.
|
||||
private readonly ProviderSiteScopeChecker $scopeChecker,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Provider|null
|
||||
@@ -109,7 +106,7 @@ final class ProviderProvider implements ProviderInterface
|
||||
// Cloisonnement par site (RG-3.17) AVANT pagination : ajoute une clause
|
||||
// restreignant au currentSite pour un user non-bypass. S'intersecte avec
|
||||
// un eventuel filtre ?siteId du client (deux sous-requetes ANDees).
|
||||
$scopeSite = $this->siteScopeOrNull();
|
||||
$scopeSite = $this->scopeChecker->siteScopeOrNull();
|
||||
if (null !== $scopeSite) {
|
||||
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
|
||||
}
|
||||
@@ -164,44 +161,14 @@ final class ProviderProvider implements ProviderInterface
|
||||
|
||||
// Cloisonnement par site (RG-3.17) : un prestataire hors du perimetre de
|
||||
// l'user -> 404 (ne pas reveler son existence). No-op pour bypass_scope ou
|
||||
// currentSite null.
|
||||
$scopeSite = $this->siteScopeOrNull();
|
||||
if (null !== $scopeSite && !$this->providerHasSite($provider, (int) $scopeSite->getId())) {
|
||||
// currentSite null (delegue au ProviderSiteScopeChecker).
|
||||
if (!$this->scopeChecker->isInScope($provider)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site de cloisonnement a appliquer en LECTURE, ou null si aucun cloisonnement
|
||||
* (user `sites.bypass_scope`, ou pas de site courant resolu — module Sites off
|
||||
* / user sans currentSite, aligne site-aware.md § 5).
|
||||
*/
|
||||
private function siteScopeOrNull(): ?SiteInterface
|
||||
{
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->currentSiteProvider->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si le prestataire est rattache (relation directe provider.sites) au
|
||||
* site d'id donne. Comparaison en memoire sur l'entite deja chargee (detail).
|
||||
*/
|
||||
private function providerHasSite(Provider $provider, int $siteId): bool
|
||||
{
|
||||
foreach ($provider->getSites() as $site) {
|
||||
if ($site instanceof SiteInterface && $site->getId() === $siteId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||
*/
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Technique\Domain\Entity\ProviderOwnedInterface;
|
||||
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider d'item des sous-ressources d'un prestataire (Contact / Adresse / RIB).
|
||||
* Decore le provider Doctrine par defaut et applique le cloisonnement par site du
|
||||
* PARENT (§ 2.13 / RG-3.17) sur Get / Patch / Delete.
|
||||
*
|
||||
* Sans ce garde, un user cloisonne pourrait lire / editer / supprimer une
|
||||
* sous-ressource d'un prestataire hors de son site : le detail Provider est bien
|
||||
* garde en 404 (ProviderProvider), mais les sous-ressources ne passent pas par lui
|
||||
* (provider Doctrine par defaut, et SiteScopedQueryExtension ne filtre que les
|
||||
* resources SiteAwareInterface — ce que ces entites ne sont pas). Le RIB est
|
||||
* particulierement sensible (IBAN / BIC).
|
||||
*
|
||||
* Hors perimetre -> retour null -> 404 (anti-enumeration, coherent avec le detail
|
||||
* Provider). La decision de scope est deleguee a ProviderSiteScopeChecker (source
|
||||
* unique partagee avec le ProviderProvider et les processors).
|
||||
*
|
||||
* @implements ProviderInterface<ProviderOwnedInterface>
|
||||
*/
|
||||
final class ProviderSubResourceItemProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.item_provider')]
|
||||
private readonly ProviderInterface $itemProvider,
|
||||
private readonly ProviderSiteScopeChecker $scopeChecker,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object
|
||||
{
|
||||
$entity = $this->itemProvider->provide($operation, $uriVariables, $context);
|
||||
|
||||
if ($entity instanceof ProviderOwnedInterface) {
|
||||
$parent = $entity->getProvider();
|
||||
if (null === $parent || !$this->scopeChecker->isInScope($parent)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Entity\ProviderContact;
|
||||
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderProvider;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Export XLSX du repertoire prestataires (M3, spec-back § 4.6). Jumeau du
|
||||
* `SupplierExportController` (M2, module Commercial), augmente du cloisonnement
|
||||
* par site pilote par l'utilisateur (§ 2.13).
|
||||
*
|
||||
* Controller Symfony custom (et non operation API Platform) car il produit un
|
||||
* binaire de fichier, pas une representation Hydra. `priority: 1` est
|
||||
* OBLIGATOIRE sur la route : sans cela API Platform capterait
|
||||
* `/api/providers/export.xlsx` comme l'item `GET /api/providers/{id}.{_format}`
|
||||
* (id="export", _format="xlsx") — cf. CLAUDE.md « controller custom sous /api ».
|
||||
*
|
||||
* Separation des responsabilites :
|
||||
* - le COMMENT (generation du fichier) est delegue au service Shared
|
||||
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
|
||||
* - le QUOI vit ICI : selection des prestataires (memes filtres que
|
||||
* `GET /api/providers`, via {@see ProviderRepositoryInterface::createListQueryBuilder()}),
|
||||
* cloisonnement par site, et mapping metier des colonnes.
|
||||
*
|
||||
* Cloisonnement par site (RG-3.17, § 2.13) : replique la logique du
|
||||
* {@see ProviderProvider}
|
||||
* — un user sans `sites.bypass_scope` et possedant un currentSite n'exporte que
|
||||
* les prestataires rattaches a ce site (relation DIRECTE provider.sites). Le
|
||||
* QueryBuilder ne connait pas l'user : la decision est prise ICI, le DQL dans le
|
||||
* repository (applySiteScope).
|
||||
*
|
||||
* Colonnes de contact : alimentees par le CONTACT PRINCIPAL du prestataire — le
|
||||
* ProviderContact de plus petit `position` (decision D2, spec § 4.6).
|
||||
*
|
||||
* La colonne SIREN n'est ajoutee que si l'utilisateur a la permission
|
||||
* `technique.providers.accounting.view` (gating identique a la lecture, § 2.9).
|
||||
*/
|
||||
#[AsController]
|
||||
final class ProviderExportController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
|
||||
private readonly ProviderRepositoryInterface $repository,
|
||||
private readonly SpreadsheetExporterInterface $exporter,
|
||||
private readonly Security $security,
|
||||
// Outillage site-aware (cf. ProviderProvider) : resout le site courant pour
|
||||
// appliquer le cloisonnement RG-3.17 a l'export comme a la liste.
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
) {}
|
||||
|
||||
#[Route('/api/providers/export.xlsx', name: 'technique_providers_export_xlsx', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('technique.providers.view')]
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
// Memes filtres d'archivage que la vue liste (ProviderProvider) pour que
|
||||
// l'export reflete exactement ce que l'utilisateur voit a l'ecran :
|
||||
// - includeArchived : inclut les archives en plus des actifs ;
|
||||
// - archivedOnly : restreint aux seules archives (prioritaire, cf.
|
||||
// createListQueryBuilder).
|
||||
$includeArchived = $this->readBool($request->query->get('includeArchived'));
|
||||
$archivedOnly = $this->readBool($request->query->get('archivedOnly'));
|
||||
$search = $request->query->getString('search') ?: null;
|
||||
|
||||
// Memes filtres que la vue liste : categoryCode/siteId tolerent une valeur
|
||||
// unique ou une liste (?categoryCode[]=A&siteId[]=1). On lit via all() pour
|
||||
// ne pas lever d'exception sur une valeur scalaire.
|
||||
$query = $request->query->all();
|
||||
$categoryCodes = $this->readStringList($query['categoryCode'] ?? []);
|
||||
$siteIds = $this->readIntList($query['siteId'] ?? []);
|
||||
|
||||
$qb = $this->repository
|
||||
->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly)
|
||||
;
|
||||
|
||||
// Cloisonnement par site (RG-3.17, § 2.13) AVANT materialisation : restreint
|
||||
// au currentSite pour un user non-bypass (s'intersecte avec un eventuel
|
||||
// ?siteId du client). No-op pour bypass_scope ou currentSite null.
|
||||
$scopeSite = $this->siteScopeOrNull();
|
||||
if (null !== $scopeSite) {
|
||||
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
|
||||
}
|
||||
|
||||
/** @var list<Provider> $providers */
|
||||
$providers = $qb->getQuery()->getResult();
|
||||
|
||||
// Hydratation batchee des collections affichees (§ 2.12) : le QB de
|
||||
// selection ne fetch-join pas les to-many. On remplit categories + sites en
|
||||
// lot (colonnes « Catégories » / « Sites »), puis les contacts (colonnes du
|
||||
// contact principal) — chacune en requetes IN bornees, anti N+1.
|
||||
$this->repository->hydrateListCollections($providers);
|
||||
$this->repository->hydrateContacts($providers);
|
||||
|
||||
$withSiren = $this->security->isGranted('technique.providers.accounting.view');
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
'Répertoire prestataires',
|
||||
$this->buildHeaders($withSiren),
|
||||
$this->buildRows($providers, $withSiren),
|
||||
);
|
||||
|
||||
return $this->buildResponse($binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Site de cloisonnement a appliquer en LECTURE, ou null si aucun cloisonnement
|
||||
* (user `sites.bypass_scope`, ou pas de site courant resolu — module Sites off
|
||||
* / user sans currentSite). Miroir de ProviderProvider::siteScopeOrNull().
|
||||
*/
|
||||
private function siteScopeOrNull(): ?SiteInterface
|
||||
{
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->currentSiteProvider->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes de l'export (spec § 4.6). SIREN inseree avant la date de creation,
|
||||
* uniquement si l'utilisateur a accounting.view.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildHeaders(bool $withSiren): array
|
||||
{
|
||||
$headers = [
|
||||
'Nom prestataire',
|
||||
'Contact principal',
|
||||
'Téléphone principal',
|
||||
'Téléphone secondaire',
|
||||
'Email',
|
||||
'Catégories',
|
||||
'Sites',
|
||||
];
|
||||
|
||||
if ($withSiren) {
|
||||
$headers[] = 'SIREN';
|
||||
}
|
||||
|
||||
$headers[] = 'Date de création';
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Provider> $providers
|
||||
*
|
||||
* @return iterable<list<null|scalar>>
|
||||
*/
|
||||
private function buildRows(array $providers, bool $withSiren): iterable
|
||||
{
|
||||
foreach ($providers as $provider) {
|
||||
$contact = $this->principalContact($provider);
|
||||
|
||||
$row = [
|
||||
$provider->getCompanyName(),
|
||||
null !== $contact ? $this->formatContactName($contact) : '',
|
||||
$contact?->getPhonePrimary() ?? '',
|
||||
$contact?->getPhoneSecondary() ?? '',
|
||||
$contact?->getEmail() ?? '',
|
||||
$this->formatCategories($provider),
|
||||
$this->formatSites($provider),
|
||||
];
|
||||
|
||||
if ($withSiren) {
|
||||
$row[] = $provider->getSiren();
|
||||
}
|
||||
|
||||
$row[] = $provider->getCreatedAt()?->format('d/m/Y');
|
||||
|
||||
yield $row;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact principal du prestataire : le ProviderContact de plus petit
|
||||
* `position` (decision D2, spec § 4.6). Null si le prestataire n'a aucun
|
||||
* contact (les colonnes contact restent vides).
|
||||
*/
|
||||
private function principalContact(Provider $provider): ?ProviderContact
|
||||
{
|
||||
$contacts = $provider->getContacts()->toArray();
|
||||
if ([] === $contacts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort(
|
||||
$contacts,
|
||||
static fn (ProviderContact $a, ProviderContact $b): int => $a->getPosition() <=> $b->getPosition(),
|
||||
);
|
||||
|
||||
return $contacts[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelle du contact principal « Nom Prénom » (spec § 4.6). Les deux parties
|
||||
* sont optionnelles (RG-3.04 : au moins l'une des deux), d'ou le trim final.
|
||||
*/
|
||||
private function formatContactName(ProviderContact $contact): string
|
||||
{
|
||||
return trim(sprintf('%s %s', $contact->getLastName() ?? '', $contact->getFirstName() ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelles des categories du prestataire, dedupliques, tries, joints par
|
||||
* virgule.
|
||||
*/
|
||||
private function formatCategories(Provider $provider): string
|
||||
{
|
||||
$names = [];
|
||||
foreach ($provider->getCategories() as $category) {
|
||||
// @var CategoryInterface $category
|
||||
$name = $category->getName();
|
||||
if (null !== $name && '' !== $name) {
|
||||
$names[$name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->joinSorted($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sites du prestataire (relation DIRECTE provider.sites, RG-3.03 — contrairement
|
||||
* au fournisseur M2 dont les sites sont portes par les adresses). La colonne
|
||||
* « Sites » agrege l'union distincte des sites rattaches.
|
||||
*/
|
||||
private function formatSites(Provider $provider): string
|
||||
{
|
||||
$names = [];
|
||||
foreach ($provider->getSites() as $site) {
|
||||
// @var SiteInterface $site
|
||||
$name = $site->getName();
|
||||
if (null !== $name && '' !== $name) {
|
||||
$names[$name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->joinSorted($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $names ensemble de libelles (cles)
|
||||
*/
|
||||
private function joinSorted(array $names): string
|
||||
{
|
||||
$list = array_keys($names);
|
||||
sort($list);
|
||||
|
||||
return implode(', ', $list);
|
||||
}
|
||||
|
||||
private function buildResponse(string $binary): Response
|
||||
{
|
||||
$filename = sprintf('repertoire-prestataires-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
|
||||
|
||||
$response = new Response($binary);
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||
* Aligne sur ProviderProvider pour un comportement identique a la liste.
|
||||
*/
|
||||
private function readBool(mixed $raw): bool
|
||||
{
|
||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste de chaines (valeur unique ou liste).
|
||||
* Aligne sur ProviderProvider pour un comportement identique a la liste.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function readStringList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_string($value) && '' !== trim($value)) {
|
||||
$out[] = trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
|
||||
* ou liste). Aligne sur ProviderProvider.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readIntList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
|
||||
$out[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\Security;
|
||||
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* Decision centralisee du cloisonnement par site des prestataires (§ 2.13 /
|
||||
* RG-3.17). Source UNIQUE partagee par le ProviderProvider (liste + detail), le
|
||||
* provider decore des sous-ressources (ProviderSubResourceItemProvider) et les
|
||||
* processors d'ecriture des sous-ressources — afin d'eviter tout drift entre ces
|
||||
* points d'application.
|
||||
*
|
||||
* Regle : un user SANS `sites.bypass_scope` ET avec un site courant ne voit /
|
||||
* n'opere que sur les prestataires rattaches (relation directe provider.sites) a
|
||||
* son site courant. `bypass_scope` (Admin inclus via isAdmin) ou absence de site
|
||||
* courant (module Sites off / user sans currentSite) -> aucun cloisonnement
|
||||
* (no-op, aligne site-aware.md § 5).
|
||||
*/
|
||||
final class ProviderSiteScopeChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Site de cloisonnement a appliquer, ou null si aucun cloisonnement
|
||||
* (`bypass_scope`, ou pas de site courant resolu).
|
||||
*/
|
||||
public function siteScopeOrNull(): ?SiteInterface
|
||||
{
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->currentSiteProvider->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si le prestataire est dans le perimetre site de l'user courant — ou si
|
||||
* aucun cloisonnement ne s'applique.
|
||||
*/
|
||||
public function isInScope(Provider $provider): bool
|
||||
{
|
||||
$scopeSite = $this->siteScopeOrNull();
|
||||
if (null === $scopeSite) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->providerHasSite($provider, (int) $scopeSite->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Leve un 404 si le prestataire est hors perimetre (anti-enumeration : ne pas
|
||||
* reveler l'existence d'une ligne hors site). No-op si dans le perimetre.
|
||||
*/
|
||||
public function assertInScope(Provider $provider): void
|
||||
{
|
||||
if (!$this->isInScope($provider)) {
|
||||
throw new NotFoundHttpException('Prestataire introuvable.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si le prestataire est rattache (relation directe provider.sites) au site
|
||||
* d'id donne. Comparaison en memoire sur l'entite deja chargee.
|
||||
*/
|
||||
private function providerHasSite(Provider $provider, int $siteId): bool
|
||||
{
|
||||
foreach ($provider->getSites() as $site) {
|
||||
if ($site instanceof SiteInterface && $site->getId() === $siteId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Application\Qualimat;
|
||||
|
||||
/**
|
||||
* Mapping pur d'un item brut de l'API QUALIMAT vers une ligne normalisee
|
||||
* prete a l'upsert dans `qualimat_carrier`. Sans dependance (testable en
|
||||
* isolation). Voir ERP-39 § 2 pour les pieges qualite de la source.
|
||||
*/
|
||||
final class QualimatRowMapper
|
||||
{
|
||||
/**
|
||||
* Mappe un lot d'items. Les items sans SIRET exploitable sont ignores et
|
||||
* comptes a part (cf. `rows_skipped` du journal). Les doublons de SIRET
|
||||
* (source "sale" : memes chiffres a separateurs pres) sont fusionnes,
|
||||
* derniere occurrence gagnante — l'upsert ne verrait qu'une ligne de toute
|
||||
* facon, et le compte `rows_upserted` reflete ainsi les transporteurs
|
||||
* distincts.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
*
|
||||
* @return array{rows: list<array<string, mixed>>, skipped: int}
|
||||
*/
|
||||
public static function mapMany(array $items): array
|
||||
{
|
||||
$bySiret = [];
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
$row = self::mapOne($item);
|
||||
|
||||
if (null === $row) {
|
||||
++$skipped;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cle = SIRET normalise : une occurrence ulterieure ecrase la
|
||||
// precedente (derniere gagnante).
|
||||
$bySiret[$row['siret']] = $row;
|
||||
}
|
||||
|
||||
return ['rows' => array_values($bySiret), 'skipped' => $skipped];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappe un item unique. Retourne null si le SIRET est absent ou vide
|
||||
* (ligne inexploitable : pas de cle naturelle pour l'upsert).
|
||||
*
|
||||
* @param array<string, mixed> $item
|
||||
*
|
||||
* @return null|array<string, mixed>
|
||||
*/
|
||||
public static function mapOne(array $item): ?array
|
||||
{
|
||||
$siret = self::normalizeSiret(self::str($item['Siret'] ?? null));
|
||||
|
||||
if (null === $siret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'siret' => $siret,
|
||||
// Nom et Societe sont identiques a la source : une seule colonne.
|
||||
'name' => self::str($item['Nom'] ?? null) ?? '',
|
||||
'address' => self::str($item['Adresse'] ?? null),
|
||||
'postal_code' => self::str($item['CodePostal'] ?? null),
|
||||
'city' => self::str($item['Ville'] ?? null),
|
||||
'phone' => self::str($item['Telephone_1'] ?? null),
|
||||
'department' => self::str($item['Departement'] ?? null),
|
||||
// Statut conserve brut (feed externe, valeurs non contraintes).
|
||||
'status' => self::str($item['Statut'] ?? null) ?? '',
|
||||
'validity_date' => self::parseDate(self::str($item['Validite'] ?? null)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un SIRET : ne conserve que les chiffres. Null si vide.
|
||||
* La source est "sale" (longueurs variables 7 a 14) : aucune contrainte
|
||||
* de longueur, on stocke les chiffres tels quels.
|
||||
*/
|
||||
public static function normalizeSiret(?string $raw): ?string
|
||||
{
|
||||
if (null === $raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D+/', '', $raw) ?? '';
|
||||
|
||||
return '' === $digits ? null : $digits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une date "dd/mm/yyyy" en "yyyy-mm-dd". Null si le format ne
|
||||
* correspond pas ou si la date n'est pas un jour calendaire valide
|
||||
* (garde-fou : evite un INSERT en erreur sur une date impossible).
|
||||
*/
|
||||
public static function parseDate(?string $raw): ?string
|
||||
{
|
||||
if (null === $raw || !preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $raw, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$day = (int) $m[1];
|
||||
$month = (int) $m[2];
|
||||
$year = (int) $m[3];
|
||||
|
||||
if (!checkdate($month, $day, $year)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim d'une valeur scalaire ; null si la chaine resultante est vide.
|
||||
*/
|
||||
private static function str(mixed $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim((string) $value);
|
||||
|
||||
return '' === $trimmed ? null : $trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\Console;
|
||||
|
||||
use App\Module\Transport\Application\Qualimat\QualimatRowMapper;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Throwable;
|
||||
|
||||
use function array_slice;
|
||||
use function count;
|
||||
use function is_array;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
/**
|
||||
* ERP-39 : synchronise le referentiel des transporteurs QUALIMAT.
|
||||
*
|
||||
* Recupere la liste des operateurs de transport depuis l'API publique (ou un
|
||||
* fichier local), normalise chaque ligne et synchronise `qualimat_carrier` de
|
||||
* facon transactionnelle : upsert sur le SIRET, soft-delete des absents,
|
||||
* journal dans `qualimat_sync_log`. Idempotente (refresh complet) : prevue
|
||||
* pour un cron quotidien.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:qualimat:sync',
|
||||
description: 'Synchronise le referentiel des transporteurs QUALIMAT (upsert + soft-delete + journal).',
|
||||
)]
|
||||
final class SyncQualimatCommand extends Command
|
||||
{
|
||||
private const string API_URL = 'https://www.qualimat.org/wp-json/qualimat/v1/getOperateurs';
|
||||
private const int DEFAULT_PPP = 10000;
|
||||
|
||||
// Cle arbitraire (mais stable) du verrou consultatif Postgres serialisant
|
||||
// les runs de `app:qualimat:sync` entre eux. Propre a cette commande.
|
||||
private const int ADVISORY_LOCK_KEY = 3_900_000_039;
|
||||
|
||||
// Nombre de lignes par INSERT groupe. 10 parametres/ligne, large marge sous
|
||||
// la limite Postgres de 65535 parametres par requete.
|
||||
private const int UPSERT_CHUNK = 1000;
|
||||
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('file', null, InputOption::VALUE_REQUIRED, "Chemin d'un JSON local (court-circuite l'appel HTTP, utile pour tests/rejeu).")
|
||||
->addOption('ppp', null, InputOption::VALUE_REQUIRED, "Taille de page demandee a l'API.", (string) self::DEFAULT_PPP)
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Analyse sans ecriture en base.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$ppp = max(1, (int) $input->getOption('ppp'));
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
$file = $input->getOption('file');
|
||||
|
||||
// Verrou consultatif (session) : empeche deux runs de se chevaucher
|
||||
// (cron qui deborde, invocation manuelle parallele). Sans lui, le run le
|
||||
// plus tardif desactiverait les lignes que l'autre vient d'inserer.
|
||||
if (!$this->acquireLock()) {
|
||||
$io->error('Une synchronisation QUALIMAT est deja en cours (verrou non disponible).');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->doSync($io, $ppp, $dryRun, $file);
|
||||
} finally {
|
||||
$this->releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coeur de la synchronisation, execute sous verrou consultatif.
|
||||
*/
|
||||
private function doSync(SymfonyStyle $io, int $ppp, bool $dryRun, ?string $file): int
|
||||
{
|
||||
// 1. Recuperation des items (fichier local ou API).
|
||||
try {
|
||||
$items = null !== $file ? $this->readLocal($file) : $this->fetchRemote($ppp);
|
||||
} catch (Throwable $e) {
|
||||
$io->error('Recuperation impossible : '.$e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$total = count($items);
|
||||
$io->section(sprintf('QUALIMAT — %d items recus', $total));
|
||||
|
||||
// Garde-fou troncature : un retour egal a ppp signale un dataset coupe.
|
||||
if (null === $file && $total === $ppp) {
|
||||
$io->warning(sprintf("Le nombre d'items recus (%d) egale --ppp : resultat potentiellement tronque, augmente --ppp.", $ppp));
|
||||
}
|
||||
|
||||
// 2. Mapping / normalisation (les items sans SIRET sont ignores, les
|
||||
// doublons de SIRET sont fusionnes : derniere occurrence gagnante).
|
||||
['rows' => $rows, 'skipped' => $skipped] = QualimatRowMapper::mapMany($items);
|
||||
$io->writeln(sprintf('%d lignes exploitables, %d ignorees (sans SIRET).', count($rows), $skipped));
|
||||
|
||||
if ($dryRun) {
|
||||
$this->renderPreview($io, $rows);
|
||||
$io->note(sprintf('Dry-run : aucune ecriture. (%d lignes au total)', count($rows)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Garde-fou « zero ligne » : une source vide (incident amont, liste []
|
||||
// legitime) ne doit JAMAIS atteindre le soft-delete, qui desactiverait
|
||||
// tout le referentiel. On abandonne sans rien ecrire.
|
||||
if ([] === $rows) {
|
||||
$io->error('Aucune ligne exploitable : synchronisation abandonnee (desactivation de masse evitee).');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// 3. Sync transactionnelle : upsert -> soft-delete -> journal.
|
||||
$run = new DateTimeImmutable()->format('Y-m-d H:i:s.u');
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
try {
|
||||
$upserted = $this->upsertAll($rows, $run);
|
||||
$deactivated = $this->deactivateMissing($run);
|
||||
$this->log($run, $total, $upserted, $skipped, $deactivated);
|
||||
$this->connection->commit();
|
||||
} catch (Throwable $e) {
|
||||
$this->connection->rollBack();
|
||||
$io->error('Sync annulee (rollback) : '.$e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d upsert, %d ignore(s), %d desactive(s).', $upserted, $skipped, $deactivated));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tente de prendre le verrou consultatif de session. Retourne false si un
|
||||
* autre run le detient deja (Postgres `pg_try_advisory_lock`, non bloquant).
|
||||
*/
|
||||
private function acquireLock(): bool
|
||||
{
|
||||
return (bool) $this->connection->fetchOne('SELECT pg_try_advisory_lock(:key)', ['key' => self::ADVISORY_LOCK_KEY]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relache le verrou consultatif pris par acquireLock().
|
||||
*/
|
||||
private function releaseLock(): void
|
||||
{
|
||||
$this->connection->executeStatement('SELECT pg_advisory_unlock(:key)', ['key' => self::ADVISORY_LOCK_KEY]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejoue l'appel GET de l'API QUALIMAT et retourne le tableau d'items.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function fetchRemote(int $ppp): array
|
||||
{
|
||||
$response = $this->httpClient->request('GET', self::API_URL, [
|
||||
'query' => ['type' => 'operateur_transport', 'ppp' => $ppp],
|
||||
'timeout' => 60,
|
||||
]);
|
||||
|
||||
// toArray() leve une exception sur un statut non-2xx ou un corps non-JSON.
|
||||
$data = $response->toArray();
|
||||
|
||||
// Un 2xx au corps inattendu (objet d'erreur, enveloppe {"data":[...]}, etc.)
|
||||
// ne doit PAS etre interprete comme « 0 transporteur » : ce serait masquer
|
||||
// un changement de contrat de l'API et declencher la desactivation de masse
|
||||
// (cf. garde-fou « zero ligne » dans execute()). On echoue franchement.
|
||||
if (!array_is_list($data)) {
|
||||
throw new RuntimeException("Reponse inattendue de l'API QUALIMAT : un tableau d'items etait attendu.");
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un export JSON local (tableau d'objets).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function readLocal(string $path): array
|
||||
{
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if (false === $raw) {
|
||||
throw new RuntimeException(sprintf('Fichier illisible : %s', $path));
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
if (!is_array($data) || !array_is_list($data)) {
|
||||
throw new RuntimeException("Le JSON doit etre un tableau d'objets.");
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert de toutes les lignes valides (cle naturelle = siret) par paquets
|
||||
* (INSERT groupe), au lieu d'un aller-retour par ligne. Marque is_active=TRUE
|
||||
* et tamponne last_synced_at avec le run courant. Les lignes etant deja
|
||||
* dedoublonnees par SIRET en amont, le compte retourne = transporteurs
|
||||
* distincts effectivement synchronises.
|
||||
*
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function upsertAll(array $rows, string $run): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
foreach (array_chunk($rows, self::UPSERT_CHUNK) as $chunk) {
|
||||
$placeholders = [];
|
||||
$params = [];
|
||||
|
||||
foreach ($chunk as $r) {
|
||||
// 10 valeurs liees + is_active force a TRUE (litteral).
|
||||
$placeholders[] = '(?, ?, ?, ?, ?, ?, ?, ?, ?, TRUE, ?)';
|
||||
$params[] = $r['siret'];
|
||||
$params[] = $r['name'];
|
||||
$params[] = $r['address'];
|
||||
$params[] = $r['postal_code'];
|
||||
$params[] = $r['city'];
|
||||
$params[] = $r['phone'];
|
||||
$params[] = $r['department'];
|
||||
$params[] = $r['status'];
|
||||
$params[] = $r['validity_date'];
|
||||
$params[] = $run;
|
||||
}
|
||||
|
||||
$sql = sprintf(
|
||||
<<<'SQL'
|
||||
INSERT INTO qualimat_carrier
|
||||
(siret, name, address, postal_code, city, phone, department, status, validity_date, is_active, last_synced_at)
|
||||
VALUES
|
||||
%s
|
||||
ON CONFLICT (siret) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
address = EXCLUDED.address,
|
||||
postal_code = EXCLUDED.postal_code,
|
||||
city = EXCLUDED.city,
|
||||
phone = EXCLUDED.phone,
|
||||
department = EXCLUDED.department,
|
||||
status = EXCLUDED.status,
|
||||
validity_date = EXCLUDED.validity_date,
|
||||
is_active = TRUE,
|
||||
last_synced_at = EXCLUDED.last_synced_at
|
||||
SQL,
|
||||
implode(",\n ", $placeholders),
|
||||
);
|
||||
|
||||
$this->connection->executeStatement($sql, $params);
|
||||
$count += count($chunk);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete : toute ligne active non revue par ce run (tampon anterieur)
|
||||
* passe a is_active=false.
|
||||
*/
|
||||
private function deactivateMissing(string $run): int
|
||||
{
|
||||
return (int) $this->connection->executeStatement(
|
||||
'UPDATE qualimat_carrier SET is_active = FALSE WHERE is_active = TRUE AND last_synced_at < :run',
|
||||
['run' => $run],
|
||||
);
|
||||
}
|
||||
|
||||
private function log(string $run, int $total, int $upserted, int $skipped, int $deactivated): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
<<<'SQL'
|
||||
INSERT INTO qualimat_sync_log (fetched_at, rows_total, rows_upserted, rows_skipped, rows_deactivated)
|
||||
VALUES (:run, :total, :upserted, :skipped, :deactivated)
|
||||
SQL,
|
||||
[
|
||||
'run' => $run,
|
||||
'total' => $total,
|
||||
'upserted' => $upserted,
|
||||
'skipped' => $skipped,
|
||||
'deactivated' => $deactivated,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function renderPreview(SymfonyStyle $io, array $rows): void
|
||||
{
|
||||
$io->table(
|
||||
['SIRET', 'Nom', 'CP', 'Ville', 'Statut', 'Validite'],
|
||||
array_map(static fn (array $r): array => [
|
||||
(string) $r['siret'],
|
||||
mb_strimwidth((string) $r['name'], 0, 40, '…'),
|
||||
(string) ($r['postal_code'] ?? ''),
|
||||
mb_strimwidth((string) ($r['city'] ?? ''), 0, 25, '…'),
|
||||
(string) $r['status'],
|
||||
(string) ($r['validity_date'] ?? ''),
|
||||
], array_slice($rows, 0, 15)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\Doctrine\Migrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-39 (Module Transport) : referentiel des transporteurs agrees QUALIMAT.
|
||||
*
|
||||
* Tables alimentees par la commande de synchronisation `app:qualimat:sync`
|
||||
* (upsert sur le SIRET + soft-delete des absents + journal). Aucune FK
|
||||
* cross-module (referentiel autonome) : migration au namespace modulaire
|
||||
* Transport. Tables autonomes, sans dependance d'ordre vis-a-vis des autres
|
||||
* migrations, donc insensible au tri cross-namespace de Doctrine Migrations.
|
||||
*/
|
||||
final class Version20260612150000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-39 : tables qualimat_carrier + qualimat_sync_log (referentiel transporteurs QUALIMAT, synchro console).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE qualimat_carrier (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
siret VARCHAR(20) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
address VARCHAR(255) DEFAULT NULL,
|
||||
postal_code VARCHAR(10) DEFAULT NULL,
|
||||
city VARCHAR(255) DEFAULT NULL,
|
||||
phone VARCHAR(32) DEFAULT NULL,
|
||||
department VARCHAR(64) DEFAULT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
validity_date DATE DEFAULT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
last_synced_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT uq_qualimat_carrier_siret UNIQUE (siret)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_qualimat_carrier_active ON qualimat_carrier (is_active)');
|
||||
|
||||
$this->comment('qualimat_carrier', '_table', "Referentiel des transporteurs agrees QUALIMAT, synchronise quotidiennement depuis l'API qualimat.org (type=operateur_transport).");
|
||||
$this->comment('qualimat_carrier', 'id', 'Cle technique auto-incrementee.');
|
||||
$this->comment('qualimat_carrier', 'siret', 'SIRET normalise (chiffres sans espaces). Cle naturelle de synchro (unique). Source parfois incomplete (longueur variable), non contrainte a 14.');
|
||||
$this->comment('qualimat_carrier', 'name', 'Raison sociale du transporteur (champs Nom = Societe de la source, identiques).');
|
||||
$this->comment('qualimat_carrier', 'address', 'Adresse postale (voie). Nullable.');
|
||||
$this->comment('qualimat_carrier', 'postal_code', 'Code postal. Nullable.');
|
||||
$this->comment('qualimat_carrier', 'city', 'Ville. Nullable.');
|
||||
$this->comment('qualimat_carrier', 'phone', 'Telephone au format source "indicatif|numero" (ex: +33|0608890316). Nullable.');
|
||||
$this->comment('qualimat_carrier', 'department', 'Departement au format source "code - libelle" (ex: 65 - Hautes-Pyrenees). Nullable.');
|
||||
$this->comment('qualimat_carrier', 'status', "Statut d'agrement QUALIMAT (valeurs connues : Audite, Valide, Suspendu). Valeur brute de la source, non contrainte.");
|
||||
$this->comment('qualimat_carrier', 'validity_date', 'Date de fin de validite de la certification (convertie depuis dd/mm/yyyy). Nullable.');
|
||||
$this->comment('qualimat_carrier', 'is_active', 'Faux = transporteur absent du dernier import (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.');
|
||||
$this->comment('qualimat_carrier', 'last_synced_at', 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE qualimat_sync_log (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
fetched_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
|
||||
rows_total INT NOT NULL,
|
||||
rows_upserted INT NOT NULL,
|
||||
rows_skipped INT NOT NULL,
|
||||
rows_deactivated INT NOT NULL,
|
||||
created_at TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW() NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->comment('qualimat_sync_log', '_table', 'Journal des synchronisations QUALIMAT (une ligne par run de la commande app:qualimat:sync).');
|
||||
$this->comment('qualimat_sync_log', 'id', 'Cle technique auto-incrementee.');
|
||||
$this->comment('qualimat_sync_log', 'fetched_at', "Horodatage de l'appel a l'API source (= run de synchro).");
|
||||
$this->comment('qualimat_sync_log', 'rows_total', "Nombre d'items renvoyes par l'API.");
|
||||
$this->comment('qualimat_sync_log', 'rows_upserted', 'Nombre de transporteurs inseres ou mis a jour.');
|
||||
$this->comment('qualimat_sync_log', 'rows_skipped', "Nombre d'items ignores (sans SIRET exploitable).");
|
||||
$this->comment('qualimat_sync_log', 'rows_deactivated', 'Nombre de transporteurs passes a is_active=false (absents de cet import).');
|
||||
$this->comment('qualimat_sync_log', 'created_at', 'Horodatage de fin du run (insertion du journal).');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS qualimat_sync_log');
|
||||
$this->addSql('DROP TABLE IF EXISTS qualimat_carrier');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pose un COMMENT ON TABLE/COLUMN en dollar-quoting Postgres ($_$...$_$)
|
||||
* pour eviter tout echappement d'apostrophes dans les descriptions.
|
||||
*/
|
||||
private function comment(string $table, string $column, string $description): void
|
||||
{
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport;
|
||||
|
||||
final class TransportModule
|
||||
{
|
||||
public const string ID = 'transport';
|
||||
public const string LABEL = 'Transport';
|
||||
public const bool REQUIRED = false;
|
||||
|
||||
/**
|
||||
* Liste declarative des permissions RBAC exposees par le module Transport.
|
||||
*
|
||||
* Vide a ce stade : le module ne porte que des referentiels externes
|
||||
* synchronises par commandes console (codes IDTF - ERP-149, transporteurs
|
||||
* QUALIMAT - ERP-39), sans ecran ni action protegee. Les permissions seront
|
||||
* ajoutees quand une page de consultation sera exposee.
|
||||
*
|
||||
* Consommee par `app:sync-permissions` (un tableau vide est valide).
|
||||
*
|
||||
* @return array<int, array{code: string, label: string}>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -395,12 +395,12 @@ final class ColumnCommentsCatalog
|
||||
],
|
||||
|
||||
'provider_contact' => [
|
||||
'_table' => 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/telephone/email (RG-3.04, chk_provider_contact_name).',
|
||||
'_table' => 'Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.',
|
||||
'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).',
|
||||
'last_name' => 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).',
|
||||
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
|
||||
'first_name' => 'Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).',
|
||||
'last_name' => 'Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).',
|
||||
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).',
|
||||
'phone_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).',
|
||||
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).',
|
||||
'email' => 'Email du contact (lowercase serveur).',
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
/**
|
||||
* Validation back-autoritative du FORMAT de la date de creation (foundedAt,
|
||||
* onglet Information).
|
||||
*
|
||||
* Le front (MalioDate, cf. MUI-44) forwarde desormais la saisie brute invalide
|
||||
* au serveur plutot que de l'avaler. Cote back, une date non parsable doit
|
||||
* produire un 422 porte sur `foundedAt` (mappable inline par useFormErrors),
|
||||
* et non un 400 generique. Repose sur `collectDenormalizationErrors` actif sur
|
||||
* l'operation Patch du Client.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientFoundedAtFormatTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string MERGE = 'application/merge-patch+json';
|
||||
|
||||
/** Date non parsable -> 422 porte sur foundedAt (et pas un 400 generique). */
|
||||
public function testFoundedAtNonParsableEst422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Founded Format SARL');
|
||||
|
||||
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['foundedAt' => '32/13/2026'],
|
||||
])->toArray(false);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cas piege : « 12/25/2026 » est invalide cote front (JJ/MM/AAAA -> mois 25)
|
||||
* mais PHP DateTime l'accepterait en M/J/AAAA (25 decembre). Le format d'entree
|
||||
* strict ISO `Y-m-d` (Context sur foundedAt) doit le rejeter -> 422.
|
||||
*/
|
||||
public function testFoundedAtFormatAmbiguUsEst422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Founded Ambigu SARL');
|
||||
|
||||
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['foundedAt' => '12/25/2026'],
|
||||
])->toArray(false);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
|
||||
}
|
||||
|
||||
/** Non-regression : une date ISO valide reste acceptee (200). */
|
||||
public function testFoundedAtIsoValideEst200(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Founded Ok SARL');
|
||||
|
||||
$data = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['foundedAt' => '2010-05-01'],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertStringStartsWith('2010-05-01', $data['foundedAt']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
/**
|
||||
* Validation back-autoritative du FORMAT de la date de creation (foundedAt,
|
||||
* onglet Information) du fournisseur. Miroir de {@see ClientFoundedAtFormatTest}.
|
||||
*
|
||||
* Une date non parsable (saisie brute forwardee par MalioDate, MUI-44) doit
|
||||
* produire un 422 porte sur `foundedAt` (mappable inline par useFormErrors), et
|
||||
* non un 400 generique. Repose sur `collectDenormalizationErrors` sur les
|
||||
* operations write du Supplier.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SupplierFoundedAtFormatTest extends AbstractSupplierApiTestCase
|
||||
{
|
||||
/** Date non parsable -> 422 porte sur foundedAt (et pas un 400 generique). */
|
||||
public function testFoundedAtNonParsableEst422(): void
|
||||
{
|
||||
$seed = $this->seedSupplier('Founded Format Negoce');
|
||||
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
|
||||
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['foundedAt' => '32/13/2026'],
|
||||
])->toArray(false);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cas piege : « 12/25/2026 » est invalide cote front (JJ/MM/AAAA -> mois 25)
|
||||
* mais PHP DateTime l'accepterait en M/J/AAAA. Le format d'entree strict ISO
|
||||
* `Y-m-d` (Context sur foundedAt) doit le rejeter -> 422.
|
||||
*/
|
||||
public function testFoundedAtFormatAmbiguUsEst422(): void
|
||||
{
|
||||
$seed = $this->seedSupplier('Founded Ambigu Negoce');
|
||||
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
|
||||
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['foundedAt' => '12/25/2026'],
|
||||
])->toArray(false);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
|
||||
}
|
||||
|
||||
/** Non-regression : une date ISO valide reste acceptee (200). */
|
||||
public function testFoundedAtIsoValideEst200(): void
|
||||
{
|
||||
$seed = $this->seedSupplier('Founded Ok Negoce');
|
||||
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
|
||||
$data = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['foundedAt' => '2010-05-01'],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertStringStartsWith('2010-05-01', $data['foundedAt']);
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,18 @@ namespace App\Tests\Module\Technique\Api;
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Entity\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;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
@@ -48,6 +55,12 @@ abstract class AbstractProviderApiTestCase extends AbstractApiTestCase
|
||||
protected const string SITE_17 = '17400'; // Saint-Jean
|
||||
protected const string SITE_82 = '82400'; // Pommevic
|
||||
|
||||
/** IBAN / BIC valides (memes valeurs que les tests M2) pour les RIB. */
|
||||
protected const string VALID_IBAN = 'FR1420041010050500013M02606';
|
||||
protected const string VALID_BIC = 'BNPAFRPPXXX';
|
||||
/** BIC d'un autre pays (DE) : controle croise pays BIC/IBAN. */
|
||||
protected const string FOREIGN_BIC = 'DEUTDEFFXXX';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
@@ -268,6 +281,195 @@ abstract class AbstractProviderApiTestCase extends AbstractApiTestCase
|
||||
return ['username' => $username, 'password' => $password];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un contact a un prestataire deja persiste (seed direct).
|
||||
*/
|
||||
protected function addContact(
|
||||
Provider $provider,
|
||||
?string $firstName = 'Marie',
|
||||
?string $lastName = 'Martin',
|
||||
?string $phonePrimary = null,
|
||||
?string $email = null,
|
||||
int $position = 0,
|
||||
): ProviderContact {
|
||||
$contact = new ProviderContact();
|
||||
$contact->setProvider($provider);
|
||||
$contact->setFirstName($firstName);
|
||||
$contact->setLastName($lastName);
|
||||
$contact->setPhonePrimary($phonePrimary);
|
||||
$contact->setEmail($email);
|
||||
$contact->setPosition($position);
|
||||
$provider->addContact($contact);
|
||||
$this->getEm()->persist($contact);
|
||||
$this->getEm()->flush();
|
||||
|
||||
return $contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un RIB a un prestataire deja persiste (seed direct).
|
||||
*/
|
||||
protected function addRib(Provider $provider, string $label = 'Compte principal'): ProviderRib
|
||||
{
|
||||
$rib = new ProviderRib();
|
||||
$rib->setProvider($provider);
|
||||
$rib->setLabel($label);
|
||||
$rib->setBic(self::VALID_BIC);
|
||||
$rib->setIban(self::VALID_IBAN);
|
||||
$provider->addRib($rib);
|
||||
$this->getEm()->persist($rib);
|
||||
$this->getEm()->flush();
|
||||
|
||||
return $rib;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
protected function paymentType(string $code): PaymentType
|
||||
{
|
||||
$paymentType = $this->getEm()->getRepository(PaymentType::class)->findOneBy(['code' => $code]);
|
||||
|
||||
self::assertNotNull(
|
||||
$paymentType,
|
||||
sprintf('Type de reglement "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
|
||||
);
|
||||
|
||||
return $paymentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere une banque seedee (CommercialReferentialFixtures) par code (ex. SG).
|
||||
* Echoue explicitement si absente (fixtures non chargees).
|
||||
*/
|
||||
protected function bank(string $code): Bank
|
||||
{
|
||||
$bank = $this->getEm()->getRepository(Bank::class)->findOneBy(['code' => $code]);
|
||||
|
||||
self::assertNotNull(
|
||||
$bank,
|
||||
sprintf('Banque "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
|
||||
);
|
||||
|
||||
return $bank;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexe les violations d'un corps 422 par propertyPath (assert ciblee).
|
||||
*
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels des RG comptables inter-champs portees par les Assert\Callback
|
||||
* de l'entite Provider (M3, RG-3.07 / RG-3.08), via le PATCH de l'onglet
|
||||
* Comptabilite (groupe provider:write:accounting). On asserte le code HTTP et le
|
||||
* propertyPath de la violation (consommable par extractApiViolations cote front,
|
||||
* ERP-101). Jumeau de SupplierAccountingApiTest (M2), completude de l'onglet
|
||||
* INCLUSE : a la validation complete de l'onglet, les six scalaires comptables
|
||||
* sont obligatoires (spec-front M3 § Onglet Comptabilite — aligne M1/M2).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderAccountingValidationTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
// === RG-3.07 : Virement impose une banque ===
|
||||
|
||||
public function testVirementWithoutBankReturns422OnBankPath(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Virement No Bank');
|
||||
|
||||
$response = $client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
|
||||
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('VIREMENT')->getId()],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('bank', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
public function testVirementWithBankReturns200(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Virement With Bank');
|
||||
|
||||
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => [
|
||||
'paymentType' => '/api/payment_types/'.$this->paymentType('VIREMENT')->getId(),
|
||||
'bank' => '/api/banks/'.$this->bank('SG')->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
}
|
||||
|
||||
// === RG-3.08 : LCR impose au moins un RIB (volet ecriture du formulaire) ===
|
||||
|
||||
public function testLcrWithoutRibReturns422OnPaymentTypePath(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Lcr No Rib');
|
||||
|
||||
$response = $client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
|
||||
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
// Miroir client : violation portee sur `paymentType` (select « Type de
|
||||
// règlement »), les RIB n'ayant pas de champ de formulaire pour l'ancrer.
|
||||
self::assertArrayHasKey('paymentType', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
public function testLcrWithRibReturns200(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Lcr With Rib');
|
||||
$this->addRib($seed);
|
||||
|
||||
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
}
|
||||
|
||||
// === Completude de l'onglet Comptabilite (six scalaires obligatoires) ===
|
||||
|
||||
/**
|
||||
* spec-front M3 § Onglet Comptabilite : a la validation COMPLETE de l'onglet
|
||||
* (les six champs requis presents dans le payload), chacun vide doit renvoyer
|
||||
* une 422 sur son propre propertyPath (mapping inline front, ERP-101). Miroir
|
||||
* M1/M2 (ProviderAccountingCompletenessValidator).
|
||||
*/
|
||||
public function testIncompleteAccountingTabReturns422OnEachField(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Accounting Incomplete');
|
||||
|
||||
$response = $client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
|
||||
'json' => [
|
||||
'siren' => null,
|
||||
'accountNumber' => null,
|
||||
'tvaMode' => null,
|
||||
'nTva' => null,
|
||||
'paymentDelay' => null,
|
||||
'paymentType' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
$paths = $this->violationsByPath($response->toArray(false));
|
||||
self::assertArrayHasKey('siren', $paths);
|
||||
self::assertArrayHasKey('accountNumber', $paths);
|
||||
self::assertArrayHasKey('tvaMode', $paths);
|
||||
self::assertArrayHasKey('nTva', $paths);
|
||||
self::assertArrayHasKey('paymentDelay', $paths);
|
||||
self::assertArrayHasKey('paymentType', $paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Un PATCH ciblant un sous-ensemble de champs comptables n'est PAS une
|
||||
* validation d'onglet : la completude ne se declenche pas (edition ponctuelle
|
||||
* preservee, cf. validateAccountingCompleteness).
|
||||
*/
|
||||
public function testPartialAccountingPatchSkipsCompleteness(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Accounting Partial');
|
||||
|
||||
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['nTva' => 'FR12345678901'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
}
|
||||
|
||||
// violationsByPath() : helper mutualise dans AbstractProviderApiTestCase.
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'export XLSX du repertoire prestataires (M3, § 4.6).
|
||||
* Jumeau du {@see \App\Tests\Module\Commercial\Api\SupplierExportControllerTest}
|
||||
* (M2), augmente du cloisonnement par site (§ 2.13, propre au M3).
|
||||
*
|
||||
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
|
||||
* archives par defaut, respect du filtre ?search, peuplement des colonnes contact
|
||||
* principal / categories / sites (relation directe provider.sites), gating de la
|
||||
* colonne SIREN selon technique.providers.accounting.view (admin ET user minimal a
|
||||
* permission explicite), dedup (prestataire multi-categories rendu sur une seule
|
||||
* ligne), cloisonnement par site (un user cloisonne n'exporte que son site), 403
|
||||
* sans technique.providers.view, 401 anonyme.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderExportControllerTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
private const string EXPORT_URL = '/api/providers/export.xlsx';
|
||||
|
||||
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProvider('Export Alpha');
|
||||
|
||||
$response = $client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$headers = $response->getHeaders(false);
|
||||
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
|
||||
|
||||
$disposition = $headers['content-disposition'][0] ?? '';
|
||||
self::assertStringContainsString('attachment; filename="repertoire-prestataires-', $disposition);
|
||||
self::assertMatchesRegularExpression(
|
||||
'/filename="repertoire-prestataires-\d{8}\.xlsx"/',
|
||||
$disposition,
|
||||
);
|
||||
|
||||
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
|
||||
$grid = $this->gridFromResponse($response->getContent());
|
||||
$headers = $grid[0];
|
||||
self::assertSame('Nom prestataire', $headers[0]);
|
||||
self::assertContains('Contact principal', $headers);
|
||||
self::assertContains('Téléphone principal', $headers);
|
||||
self::assertContains('Téléphone secondaire', $headers);
|
||||
self::assertContains('Email', $headers);
|
||||
self::assertContains('Catégories', $headers);
|
||||
self::assertContains('Sites', $headers);
|
||||
self::assertContains('Date de création', $headers);
|
||||
}
|
||||
|
||||
public function testExportExcludesArchivedByDefault(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProvider('Active One');
|
||||
$this->seedProvider('Archived One', [self::SITE_86], true);
|
||||
|
||||
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('ACTIVE ONE', $names);
|
||||
self::assertNotContains('ARCHIVED ONE', $names);
|
||||
}
|
||||
|
||||
public function testExportRespectsSearchFilter(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProvider('Searchable Alpha');
|
||||
$this->seedProvider('Other Beta');
|
||||
|
||||
$names = $this->companyNames(
|
||||
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
|
||||
);
|
||||
|
||||
self::assertContains('SEARCHABLE ALPHA', $names);
|
||||
self::assertNotContains('OTHER BETA', $names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les colonnes contact sont alimentees par le CONTACT PRINCIPAL : le contact
|
||||
* de plus petit `position` (decision D2, § 4.6). On seede deux contacts en
|
||||
* ordre de position inverse pour garantir que c'est bien le principal (et non
|
||||
* le premier insere) qui alimente la ligne.
|
||||
*/
|
||||
public function testExportUsesPrincipalContactColumns(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$provider = $this->seedProvider('Contact Co');
|
||||
|
||||
// position 1 (secondaire) insere en premier...
|
||||
$this->addContact($provider, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1);
|
||||
// ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
|
||||
$principal = $this->addContact($provider, 'Alice', 'Principal', '0612345678', 'alice@contact.co', 0);
|
||||
// Le telephone secondaire n'est pas porte par le helper de base : on le pose
|
||||
// directement sur le contact principal pour alimenter la colonne dediee.
|
||||
$principal->setPhoneSecondary('0698765432');
|
||||
$this->getEm()->flush();
|
||||
|
||||
$row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO');
|
||||
|
||||
self::assertNotNull($row, 'Ligne « CONTACT CO » introuvable dans l\'export.');
|
||||
self::assertSame('Principal Alice', $row[1]);
|
||||
self::assertSame('0612345678', $row[2]);
|
||||
self::assertSame('0698765432', $row[3]);
|
||||
self::assertSame('alice@contact.co', $row[4]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes « Catégories » et « Sites » : un oubli d'hydratation les rendrait
|
||||
* vides sans erreur (cf. ERP-100 cote client). Le site est porte EN DIRECT par
|
||||
* le prestataire (RG-3.03), contrairement au fournisseur M2 (via l'adresse).
|
||||
*/
|
||||
public function testExportPopulatesCategoryAndSiteColumns(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProvider('Hydrate Co', [self::SITE_86], false, 'NETTOYAGE');
|
||||
|
||||
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
|
||||
|
||||
// Colonne « Catégories » : libelle de la categorie PRESTATAIRE (getName()).
|
||||
// Derive du helper de base (idempotent) plutot que de hardcoder le prefixe.
|
||||
self::assertStringContainsString((string) $this->providerCategory('NETTOYAGE')->getName(), $flat);
|
||||
// Colonne « Sites » : site rattache en direct au prestataire (RG-3.03).
|
||||
self::assertStringContainsString((string) $this->site(self::SITE_86)->getName(), $flat);
|
||||
}
|
||||
|
||||
public function testSirenColumnPresentWithAccountingView(): void
|
||||
{
|
||||
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProvider('Siren Co', [self::SITE_86], false, 'NETTOYAGE', '123456789');
|
||||
|
||||
$grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('SIREN', $grid[0]);
|
||||
self::assertStringContainsString('123456789', $this->flatten($grid));
|
||||
}
|
||||
|
||||
public function testSirenColumnAbsentWithoutAccountingView(): void
|
||||
{
|
||||
// Seed via admin, puis relecture par un user qui n'a QUE providers.view.
|
||||
$this->createAdminClient();
|
||||
$this->seedProvider('No Siren Co', [self::SITE_86], false, 'NETTOYAGE', '987654321');
|
||||
|
||||
$creds = $this->createUserWithPermission('technique.providers.view');
|
||||
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertNotContains('SIREN', $grid[0]);
|
||||
self::assertStringNotContainsString('987654321', $this->flatten($grid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) :
|
||||
* un user minimal portant uniquement technique.providers.view +
|
||||
* technique.providers.accounting.view voit bien la colonne SIREN et sa valeur.
|
||||
* Complement de testSirenColumnPresentWithAccountingView (admin), qui ne prouve
|
||||
* pas que accounting.view SEULE suffit (l'admin bypasse le RBAC). Le pendant
|
||||
* negatif est couvert par testSirenColumnAbsentWithoutAccountingView.
|
||||
*/
|
||||
public function testSirenColumnPresentForMinimalUserWithAccountingView(): void
|
||||
{
|
||||
// Seed via admin, puis relecture par un user non-admin a 2 permissions.
|
||||
$this->createAdminClient();
|
||||
$this->seedProvider('Gated Siren Co', [self::SITE_86], false, 'NETTOYAGE', '456789123');
|
||||
|
||||
$creds = $this->createUserWithPermissions([
|
||||
'technique.providers.view',
|
||||
'technique.providers.accounting.view',
|
||||
]);
|
||||
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('SIREN', $grid[0]);
|
||||
self::assertStringContainsString('456789123', $this->flatten($grid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedup : un prestataire portant >= 2 categories PRESTATAIRE est multiplie par
|
||||
* la jointure (selection/hydratation des collections) ; l'export doit le rendre
|
||||
* sur UNE SEULE ligne. On seede un prestataire a 2 categories et on assert qu'il
|
||||
* n'apparait qu'une fois dans la colonne « Nom prestataire ».
|
||||
*/
|
||||
public function testExportDeduplicatesProviderWithMultipleCategories(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$provider = $this->seedProvider('Multi Cat Co', [self::SITE_86], false, 'NETTOYAGE');
|
||||
// 2e categorie PRESTATAIRE sur le meme prestataire.
|
||||
$provider->addCategory($this->providerCategory('SECURITE'));
|
||||
$this->getEm()->flush();
|
||||
|
||||
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
$occurrences = count(array_filter($names, static fn (string $name): bool => 'MULTI CAT CO' === $name));
|
||||
self::assertSame(
|
||||
1,
|
||||
$occurrences,
|
||||
'Un prestataire multi-categories doit apparaitre sur une seule ligne (dedup).',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloisonnement par site (RG-3.17, § 2.13) : un user non-bypass cloisonne sur
|
||||
* le site 86 n'exporte QUE les prestataires rattaches au site 86 — les
|
||||
* prestataires des sites 17 / 82 sont exclus, comme dans la liste. Pendant
|
||||
* export de ProviderSiteScopeTest::testListIsScopedToCurrentSiteForNonBypassUser.
|
||||
*/
|
||||
public function testExportIsScopedToCurrentSiteForNonBypassUser(): void
|
||||
{
|
||||
// Pre-requis : module Sites actif (sinon currentSite = null, cloisonnement
|
||||
// no-op et ce test perd son sens).
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$this->createAdminClient();
|
||||
$this->seedProvider('Presta Site 86', [self::SITE_86]);
|
||||
$this->seedProvider('Presta Site 17', [self::SITE_17]);
|
||||
$this->seedProvider('Presta Site 82', [self::SITE_82]);
|
||||
|
||||
$creds = $this->createScopedUser(
|
||||
['technique.providers.view'],
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('PRESTA SITE 86', $names);
|
||||
self::assertNotContains('PRESTA SITE 17', $names);
|
||||
self::assertNotContains('PRESTA SITE 82', $names);
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutProvidersViewPermission(): void
|
||||
{
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testUnauthorizedWhenAnonymous(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
|
||||
*
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
private function gridFromResponse(string $binary): array
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_');
|
||||
self::assertIsString($tmp);
|
||||
file_put_contents($tmp, $binary);
|
||||
|
||||
try {
|
||||
return IOFactory::load($tmp)->getActiveSheet()->toArray();
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait la colonne « Nom prestataire » (1re colonne) des lignes de donnees.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function companyNames(string $binary): array
|
||||
{
|
||||
$grid = $this->gridFromResponse($binary);
|
||||
$rows = array_slice($grid, 1); // saute l'en-tete
|
||||
|
||||
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName.
|
||||
*
|
||||
* @return null|array<int, mixed>
|
||||
*/
|
||||
private function rowFor(string $binary, string $companyName): ?array
|
||||
{
|
||||
foreach (array_slice($this->gridFromResponse($binary), 1) as $row) {
|
||||
if ((string) ($row[0] ?? '') === $companyName) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplatit toute la grille en une chaine, pour les assertions de presence.
|
||||
*
|
||||
* @param array<int, array<int, mixed>> $grid
|
||||
*/
|
||||
private function flatten(array $grid): string
|
||||
{
|
||||
return implode('|', array_map(
|
||||
static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)),
|
||||
$grid,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user