Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8be14a299 |
+2
-2
@@ -24,7 +24,6 @@
|
||||
"symfony/expression-language": "8.0.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/http-client": "8.0.*",
|
||||
"symfony/intl": "8.0.*",
|
||||
"symfony/mime": "8.0.*",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
@@ -96,6 +95,7 @@
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||
"friendsofphp/php-cs-fixer": "^3.94",
|
||||
"phpunit/phpunit": "^13.0",
|
||||
"symfony/browser-kit": "8.0.*"
|
||||
"symfony/browser-kit": "8.0.*",
|
||||
"symfony/http-client": "8.0.*"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+175
-175
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "b029c1484227c926d39dfd3ae5cb0699",
|
||||
"content-hash": "2dc5db01e7f5d6aecd5956749b21a092",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -5412,180 +5412,6 @@
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v8.0.13",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "c7f40f9103233630167c25c9a4570acf805fdade"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/c7f40f9103233630167c25c9a4570acf805fdade",
|
||||
"reference": "c7f40f9103233630167c25c9a4570acf805fdade",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"amphp/amp": "<3",
|
||||
"php-http/discovery": "<1.15"
|
||||
},
|
||||
"provide": {
|
||||
"php-http/async-client-implementation": "*",
|
||||
"php-http/client-implementation": "*",
|
||||
"psr/http-client-implementation": "1.0",
|
||||
"symfony/http-client-implementation": "3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"amphp/http-client": "^5.3.2",
|
||||
"amphp/http-tunnel": "^2.0",
|
||||
"guzzlehttp/promises": "^1.4|^2.0",
|
||||
"nyholm/psr7": "^1.0",
|
||||
"php-http/httplug": "^1.0|^2.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/cache": "^7.4|^8.0",
|
||||
"symfony/dependency-injection": "^7.4|^8.0",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/messenger": "^7.4|^8.0",
|
||||
"symfony/process": "^7.4|^8.0",
|
||||
"symfony/rate-limiter": "^7.4|^8.0",
|
||||
"symfony/stopwatch": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client/tree/v8.0.13"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-24T09:58:02+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client-contracts",
|
||||
"version": "v3.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client-contracts.git",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/contracts",
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.6-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Contracts\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Test/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Generic abstractions related to HTTP clients",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"abstractions",
|
||||
"contracts",
|
||||
"decoupling",
|
||||
"interfaces",
|
||||
"interoperability",
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-04-29T11:18:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-foundation",
|
||||
"version": "v8.0.8",
|
||||
@@ -11959,6 +11785,180 @@
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v8.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e",
|
||||
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"amphp/amp": "<3",
|
||||
"php-http/discovery": "<1.15"
|
||||
},
|
||||
"provide": {
|
||||
"php-http/async-client-implementation": "*",
|
||||
"php-http/client-implementation": "*",
|
||||
"psr/http-client-implementation": "1.0",
|
||||
"symfony/http-client-implementation": "3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"amphp/http-client": "^5.3.2",
|
||||
"amphp/http-tunnel": "^2.0",
|
||||
"guzzlehttp/promises": "^1.4|^2.0",
|
||||
"nyholm/psr7": "^1.0",
|
||||
"php-http/httplug": "^1.0|^2.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/cache": "^7.4|^8.0",
|
||||
"symfony/dependency-injection": "^7.4|^8.0",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/messenger": "^7.4|^8.0",
|
||||
"symfony/process": "^7.4|^8.0",
|
||||
"symfony/rate-limiter": "^7.4|^8.0",
|
||||
"symfony/stopwatch": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client/tree/v8.0.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client-contracts",
|
||||
"version": "v3.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client-contracts.git",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/contracts",
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.6-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Contracts\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Test/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Generic abstractions related to HTTP clients",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"abstractions",
|
||||
"contracts",
|
||||
"decoupling",
|
||||
"interfaces",
|
||||
"interoperability",
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-04-29T11:18:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/process",
|
||||
"version": "v8.0.8",
|
||||
|
||||
@@ -6,7 +6,6 @@ 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,
|
||||
@@ -14,5 +13,4 @@ return [
|
||||
SitesModule::class,
|
||||
CatalogModule::class,
|
||||
TechniqueModule::class,
|
||||
TransportModule::class,
|
||||
];
|
||||
|
||||
@@ -12,9 +12,6 @@ api_platform:
|
||||
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
|
||||
# en dehors de Domain/Entity : AuditLogResource, etc.
|
||||
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
|
||||
# Entites techniques partagees portant un #[ApiResource]
|
||||
# (UploadedDocument — infra upload generique ERP-154).
|
||||
- '%kernel.project_dir%/src/Shared/Domain/Entity'
|
||||
formats:
|
||||
jsonld: ['application/ld+json']
|
||||
json: ['application/json']
|
||||
|
||||
@@ -8,24 +8,16 @@ doctrine:
|
||||
default:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
# 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.
|
||||
# - `idtf_product` / `idtf_sync_log` : referentiel codes IDTF
|
||||
# synchronise en DBAL brut par `app:idtf: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 ; idtf : Version20260612160000).
|
||||
schema_filter: '~^(?!(?:audit_log|qualimat_carrier|qualimat_sync_log|idtf_product|idtf_sync_log)$).+~'
|
||||
# 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$).+~'
|
||||
audit:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
orm:
|
||||
@@ -50,16 +42,6 @@ doctrine:
|
||||
# Shared sans importer la classe concrete du module Catalog (regle n°1).
|
||||
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
|
||||
mappings:
|
||||
# Mapping des entites techniques partagees (src/Shared/Domain/Entity).
|
||||
# Premier occupant : UploadedDocument (infra upload generique ERP-154).
|
||||
# Necessaire car les entites Shared ne sont pas couvertes par
|
||||
# l'auto_mapping (qui ne cible que les bundles).
|
||||
Shared:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Shared/Domain/Entity'
|
||||
prefix: 'App\Shared\Domain\Entity'
|
||||
alias: Shared
|
||||
Core:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
|
||||
@@ -2,5 +2,4 @@ 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
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Active le composant HTTP Client (symfony/http-client) et enregistre
|
||||
# l'autowiring de HttpClientInterface. Utilise par les commandes de
|
||||
# synchronisation de referentiels externes (QUALIMAT, IDTF...).
|
||||
#
|
||||
# User-Agent navigateur neutre : les sources (qualimat.org sous WordPress/WAF,
|
||||
# icrt-idtf.com) filtrent souvent les UA de bibliotheque/vides ; un UA de type
|
||||
# navigateur evite les blocages anti-bot sans reveler l'application.
|
||||
framework:
|
||||
http_client:
|
||||
default_options:
|
||||
timeout: 30
|
||||
headers:
|
||||
User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
||||
@@ -61,23 +61,6 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Technique" (M3, ERP-138) : pole distinct du Commercial, porte le
|
||||
// repertoire prestataires. L'item est gate par `technique.providers.view` ;
|
||||
// la section disparait automatiquement (SidebarProvider) si le module
|
||||
// `technique` est desactive ou si l'user n'a pas la permission.
|
||||
[
|
||||
'label' => 'sidebar.technique.section',
|
||||
'icon' => 'mdi: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.126'
|
||||
app.version: '0.1.110'
|
||||
|
||||
@@ -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** (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.
|
||||
- **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`).
|
||||
|
||||
> **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,153 +624,67 @@ 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`).
|
||||
|
||||
> **✅ 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) :
|
||||
`GET /api/providers` (liste, ADMIN — un membre, forme attendue) :
|
||||
```json
|
||||
{
|
||||
"@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"}
|
||||
"@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"}
|
||||
}
|
||||
```
|
||||
|
||||
> 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).
|
||||
> 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.
|
||||
|
||||
`GET /api/providers/{id}` (détail — user **avec** `accounting.view`, capture réelle) :
|
||||
`GET /api/providers/{id}` (détail — user avec `accounting.view`, forme attendue) :
|
||||
```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"}
|
||||
],
|
||||
"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
|
||||
"@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
|
||||
}
|
||||
```
|
||||
|
||||
`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`.
|
||||
> 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).
|
||||
|
||||
### 4.1 `GET /api/providers` — Liste
|
||||
|
||||
@@ -1009,7 +923,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**
|
||||
- [x] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — liste + détail (avec/sans `accounting.view`) collés depuis `ProviderSerializationContractTest` (ERP-139)
|
||||
- [ ] **Réponses JSON RÉELLES** à capturer (§ 4.0.bis) — gabarit posé, capture à faire au 1er ticket back (DoD avant front)
|
||||
- [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,10 +30,6 @@
|
||||
"clients": "Répertoire clients",
|
||||
"suppliers": "Répertoire fournisseurs"
|
||||
},
|
||||
"technique": {
|
||||
"section": "Technique",
|
||||
"providers": "Répertoire prestataires"
|
||||
},
|
||||
"core": {
|
||||
"roles": "Gestion des rôles",
|
||||
"users": "Utilisateurs",
|
||||
@@ -366,130 +362,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -157,16 +157,12 @@
|
||||
<!-- Onglet Contact -->
|
||||
<template #contact>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||
bloc enregistre. -->
|
||||
<ClientContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="contact.id ?? `new-${index}`"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:removable="contacts.length > 1"
|
||||
:readonly="businessReadonly"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -203,7 +199,7 @@
|
||||
:site-options="siteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:removable="addresses.length > 1"
|
||||
:readonly="businessReadonly"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@@ -308,7 +304,7 @@
|
||||
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 && isRowRemovable(visibleRibs, index)"
|
||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -444,7 +440,6 @@ import {
|
||||
type RibFormDraft,
|
||||
} from '~/modules/commercial/types/clientForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
|
||||
// Masques de saisie (la normalisation finale reste serveur).
|
||||
@@ -495,6 +490,10 @@ const contacts = ref<ContactFormDraft[]>([])
|
||||
const addresses = ref<AddressFormDraft[]>([])
|
||||
const ribs = ref<RibFormDraft[]>([])
|
||||
|
||||
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
|
||||
const removedContactIds = ref<number[]>([])
|
||||
const removedAddressIds = ref<number[]>([])
|
||||
const removedRibIds = ref<number[]>([])
|
||||
|
||||
const mainSubmitting = ref(false)
|
||||
const tabSubmitting = ref(false)
|
||||
@@ -755,31 +754,32 @@ function addContact(): void {
|
||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||
}
|
||||
|
||||
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
|
||||
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
|
||||
// local. Echec serveur : bloc conserve + erreur remontee.
|
||||
function askRemoveContact(index: number): void {
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => removeCollectionRow({
|
||||
rows: contacts.value,
|
||||
errors: contactErrors.value,
|
||||
index,
|
||||
endpoint: '/client_contacts',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyContact,
|
||||
onError: showError,
|
||||
}))
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
|
||||
const removed = contacts.value[index]
|
||||
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
||||
contacts.value.splice(index, 1)
|
||||
contactErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Contact : POST/PATCH des blocs restants sur la sous-ressource.
|
||||
* Strictement scope a la collection contacts (endpoints client_contact dedies). La
|
||||
* suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
|
||||
* Valide l'onglet Contact : DELETE des contacts retires (existants), puis
|
||||
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
||||
* collection contacts (endpoints client_contact dedies).
|
||||
*/
|
||||
async function submitContacts(): Promise<void> {
|
||||
if (businessReadonly.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
contactErrors.value = []
|
||||
try {
|
||||
for (const id of removedContactIds.value) {
|
||||
await api.delete(`/client_contacts/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedContactIds.value = []
|
||||
|
||||
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
|
||||
// amorces neuves vides (ex. tous les contacts existants supprimes), on ne
|
||||
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
|
||||
@@ -836,15 +836,14 @@ function addAddress(): void {
|
||||
}
|
||||
|
||||
function askRemoveAddress(index: number): void {
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => removeCollectionRow({
|
||||
rows: addresses.value,
|
||||
errors: addressErrors.value,
|
||||
index,
|
||||
endpoint: '/client_addresses',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyAddress,
|
||||
onError: showError,
|
||||
}))
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
|
||||
const removed = addresses.value[index]
|
||||
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
||||
addresses.value.splice(index, 1)
|
||||
addressErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
||||
})
|
||||
}
|
||||
|
||||
function onAddressDegraded(): void {
|
||||
@@ -856,12 +855,17 @@ function onAddressDegraded(): void {
|
||||
})
|
||||
}
|
||||
|
||||
/** Valide l'onglet Adresse : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
|
||||
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
|
||||
async function submitAddresses(): Promise<void> {
|
||||
if (businessReadonly.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
addressErrors.value = []
|
||||
try {
|
||||
for (const id of removedAddressIds.value) {
|
||||
await api.delete(`/client_addresses/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedAddressIds.value = []
|
||||
|
||||
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
|
||||
const hasError = await submitRows(
|
||||
addresses.value,
|
||||
@@ -933,32 +937,29 @@ function addRib(): void {
|
||||
if (canAddRib.value) ribs.value.push(emptyRib())
|
||||
}
|
||||
|
||||
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
|
||||
// d'une LCR (RG-1.13) -> 409 remonte via showError (message back), bloc conserve.
|
||||
function askRemoveRib(index: number): void {
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => removeCollectionRow({
|
||||
rows: ribs.value,
|
||||
errors: ribErrors.value,
|
||||
index,
|
||||
endpoint: '/client_ribs',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyRib,
|
||||
onError: showError,
|
||||
}))
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
|
||||
const removed = ribs.value[index]
|
||||
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
||||
ribs.value.splice(index, 1)
|
||||
ribErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
|
||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
||||
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
|
||||
* back). Les RIB crees d'abord : le back valide RG-1.13 (LCR => au moins un RIB
|
||||
* persiste) sur le PATCH scalaires.
|
||||
* back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back
|
||||
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
||||
*
|
||||
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
|
||||
* plus de DELETE differe ici.
|
||||
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
||||
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-1.28 :
|
||||
* sinon 403 sur tout le payload).
|
||||
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
||||
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement
|
||||
* de type de reglement. Aucun champ main/information dans le payload (mode strict
|
||||
* RG-1.28 : sinon 403 sur tout le payload).
|
||||
*/
|
||||
async function submitAccounting(): Promise<void> {
|
||||
if (accountingReadonly.value || tabSubmitting.value) return
|
||||
@@ -1012,6 +1013,14 @@ async function submitAccounting(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
|
||||
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
|
||||
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
|
||||
for (const id of removedRibIds.value) {
|
||||
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedRibIds.value = []
|
||||
|
||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
@@ -156,16 +156,12 @@
|
||||
<!-- Onglet Contact -->
|
||||
<template #contact>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||
bloc enregistre. -->
|
||||
<ClientContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:removable="index > 0"
|
||||
:readonly="isValidated('contact')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -202,7 +198,7 @@
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:removable="index > 0"
|
||||
:readonly="isValidated('address')"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@@ -307,7 +303,7 @@
|
||||
>
|
||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -421,7 +417,6 @@ import {
|
||||
type RibFormDraft,
|
||||
} from '~/modules/commercial/types/clientForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||
|
||||
// Masques de saisie (la normalisation finale reste serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
|
||||
@@ -126,16 +126,12 @@
|
||||
<!-- Onglet Contacts -->
|
||||
<template #contacts>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||
bloc enregistre. -->
|
||||
<SupplierContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="contact.id ?? `new-${index}`"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:removable="contacts.length > 1"
|
||||
:readonly="businessReadonly"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -172,7 +168,7 @@
|
||||
:site-options="siteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:removable="addresses.length > 1"
|
||||
:readonly="businessReadonly"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@@ -277,7 +273,7 @@
|
||||
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 && isRowRemovable(visibleRibs, index)"
|
||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -411,7 +407,6 @@ import {
|
||||
type SupplierRibFormDraft,
|
||||
} from '~/modules/commercial/types/supplierForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
|
||||
// Masques de saisie (la normalisation finale reste serveur).
|
||||
@@ -461,6 +456,10 @@ const contacts = ref<SupplierContactFormDraft[]>([])
|
||||
const addresses = ref<SupplierAddressFormDraft[]>([])
|
||||
const ribs = ref<SupplierRibFormDraft[]>([])
|
||||
|
||||
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
|
||||
const removedContactIds = ref<number[]>([])
|
||||
const removedAddressIds = ref<number[]>([])
|
||||
const removedRibIds = ref<number[]>([])
|
||||
|
||||
const mainSubmitting = ref(false)
|
||||
const tabSubmitting = ref(false)
|
||||
@@ -654,31 +653,32 @@ function addContact(): void {
|
||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||
}
|
||||
|
||||
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
|
||||
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
|
||||
// local. Echec serveur : bloc conserve + erreur remontee.
|
||||
function askRemoveContact(index: number): void {
|
||||
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => removeCollectionRow({
|
||||
rows: contacts.value,
|
||||
errors: contactErrors.value,
|
||||
index,
|
||||
endpoint: '/supplier_contacts',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyContact,
|
||||
onError: showError,
|
||||
}))
|
||||
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => {
|
||||
const removed = contacts.value[index]
|
||||
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
||||
contacts.value.splice(index, 1)
|
||||
contactErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Contacts : POST/PATCH des blocs restants sur la sous-ressource.
|
||||
* Strictement scope a la collection contacts (endpoints supplier_contact dedies).
|
||||
* La suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
|
||||
* Valide l'onglet Contacts : DELETE des contacts retires (existants), puis
|
||||
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
||||
* collection contacts (endpoints supplier_contact dedies).
|
||||
*/
|
||||
async function submitContacts(): Promise<void> {
|
||||
if (businessReadonly.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
contactErrors.value = []
|
||||
try {
|
||||
for (const id of removedContactIds.value) {
|
||||
await api.delete(`/supplier_contacts/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedContactIds.value = []
|
||||
|
||||
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
|
||||
// amorces neuves vides, on les soumet -> 422 RG-2.04 inline (nom OU prenom).
|
||||
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
|
||||
@@ -726,15 +726,14 @@ function addAddress(): void {
|
||||
}
|
||||
|
||||
function askRemoveAddress(index: number): void {
|
||||
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => removeCollectionRow({
|
||||
rows: addresses.value,
|
||||
errors: addressErrors.value,
|
||||
index,
|
||||
endpoint: '/supplier_addresses',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyAddress,
|
||||
onError: showError,
|
||||
}))
|
||||
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => {
|
||||
const removed = addresses.value[index]
|
||||
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
||||
addresses.value.splice(index, 1)
|
||||
addressErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
||||
})
|
||||
}
|
||||
|
||||
function onAddressDegraded(): void {
|
||||
@@ -746,12 +745,17 @@ function onAddressDegraded(): void {
|
||||
})
|
||||
}
|
||||
|
||||
/** Valide l'onglet Adresses : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
|
||||
/** Valide l'onglet Adresses : DELETE des adresses retirees puis POST/PATCH. */
|
||||
async function submitAddresses(): Promise<void> {
|
||||
if (businessReadonly.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
addressErrors.value = []
|
||||
try {
|
||||
for (const id of removedAddressIds.value) {
|
||||
await api.delete(`/supplier_addresses/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedAddressIds.value = []
|
||||
|
||||
const hasError = await submitRows(
|
||||
addresses.value,
|
||||
addressErrors,
|
||||
@@ -822,18 +826,15 @@ function addRib(): void {
|
||||
if (canAddRib.value) ribs.value.push(emptyRib())
|
||||
}
|
||||
|
||||
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
|
||||
// d'une LCR (RG-2.08) -> 409 remonte via showError (message back), bloc conserve.
|
||||
function askRemoveRib(index: number): void {
|
||||
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => removeCollectionRow({
|
||||
rows: ribs.value,
|
||||
errors: ribErrors.value,
|
||||
index,
|
||||
endpoint: '/supplier_ribs',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyRib,
|
||||
onError: showError,
|
||||
}))
|
||||
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => {
|
||||
const removed = ribs.value[index]
|
||||
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
||||
ribs.value.splice(index, 1)
|
||||
ribErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
|
||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -842,12 +843,11 @@ function askRemoveRib(index: number): void {
|
||||
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
|
||||
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
||||
*
|
||||
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
|
||||
* plus de DELETE differe ici.
|
||||
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
||||
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-2.16 :
|
||||
* sinon 403 sur tout le payload).
|
||||
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
||||
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le
|
||||
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
|
||||
*/
|
||||
async function submitAccounting(): Promise<void> {
|
||||
if (accountingReadonly.value || tabSubmitting.value) return
|
||||
@@ -897,6 +897,14 @@ async function submitAccounting(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
|
||||
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
|
||||
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
|
||||
for (const id of removedRibIds.value) {
|
||||
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedRibIds.value = []
|
||||
|
||||
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
@@ -121,16 +121,12 @@
|
||||
<!-- Onglet Contacts -->
|
||||
<template #contacts>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||
bloc enregistre. -->
|
||||
<SupplierContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:removable="index > 0"
|
||||
:readonly="isValidated('contacts')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -167,7 +163,7 @@
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:removable="index > 0"
|
||||
:readonly="isValidated('addresses')"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@@ -271,7 +267,7 @@
|
||||
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 && isRowRemovable(visibleRibs, index)"
|
||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -384,7 +380,6 @@ import {
|
||||
type SupplierRibFormDraft,
|
||||
} from '~/modules/commercial/types/supplierForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||
|
||||
// Masques de saisie (la normalisation finale reste serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
<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>
|
||||
@@ -1,108 +0,0 @@
|
||||
<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>
|
||||
@@ -1,157 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
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('')
|
||||
})
|
||||
})
|
||||
@@ -1,653 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,78 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -1,645 +0,0 @@
|
||||
import { computed, reactive, ref, type Ref } from 'vue'
|
||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
||||
import { removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||
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()
|
||||
|
||||
// ERP-172 : remontee d'erreur 409/422 lors d'une suppression immediate de
|
||||
// sous-ressource (message back affiche en toast dedie — pas de mapping inline,
|
||||
// le bloc est en cours de retrait). Ex. dernier RIB d'une LCR -> 409.
|
||||
function notifyRemovalError(error: unknown): void {
|
||||
toast.error({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: extractApiErrorMessage((error as { data?: unknown })?.data) || t('technique.providers.toast.error'),
|
||||
})
|
||||
}
|
||||
|
||||
// ── 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())
|
||||
}
|
||||
}
|
||||
|
||||
// ERP-172 : DELETE immediat du contact existant (sous-ressource) a la
|
||||
// confirmation de la modale. Bloc jamais persiste (id null) : retrait local.
|
||||
async function removeContact(index: number): Promise<void> {
|
||||
await removeCollectionRow({
|
||||
rows: contacts.value,
|
||||
errors: contactErrors.value,
|
||||
index,
|
||||
endpoint: '/provider_contacts',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyProviderContact,
|
||||
onError: notifyRemovalError,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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())
|
||||
}
|
||||
}
|
||||
|
||||
// ERP-172 : DELETE immediat de l'adresse existante (sous-ressource).
|
||||
async function removeAddress(index: number): Promise<void> {
|
||||
await removeCollectionRow({
|
||||
rows: addresses.value,
|
||||
errors: addressErrors.value,
|
||||
index,
|
||||
endpoint: '/provider_addresses',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyProviderAddress,
|
||||
onError: notifyRemovalError,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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())
|
||||
}
|
||||
}
|
||||
|
||||
// ERP-172 : DELETE immediat du RIB existant. Le back peut refuser la suppression
|
||||
// du dernier RIB d'une LCR -> 409 remonte via notifyRemovalError, bloc conserve.
|
||||
async function removeRib(index: number): Promise<void> {
|
||||
await removeCollectionRow({
|
||||
rows: ribs.value,
|
||||
errors: ribErrors.value,
|
||||
index,
|
||||
endpoint: '/provider_ribs',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyProviderRib,
|
||||
onError: notifyRemovalError,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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' })
|
||||
}
|
||||
@@ -1,543 +0,0 @@
|
||||
<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">
|
||||
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||
bloc enregistre. -->
|
||||
<ProviderContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
: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="isRowRemovable(addresses, index)"
|
||||
: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 && isRowRemovable(visibleRibs, index)"
|
||||
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'
|
||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||
|
||||
// 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>
|
||||
@@ -1,308 +0,0 @@
|
||||
<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>
|
||||
@@ -1,438 +0,0 @@
|
||||
<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>
|
||||
@@ -1,535 +0,0 @@
|
||||
<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">
|
||||
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||
bloc enregistre. -->
|
||||
<ProviderContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
: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="isRowRemovable(addresses, index)"
|
||||
: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 && isRowRemovable(visibleRibs, index)"
|
||||
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'
|
||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||
|
||||
// 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>
|
||||
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
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...' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,93 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,167 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export default defineNuxtConfig({})
|
||||
@@ -1,121 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { removeCollectionRow, isRowRemovable, type DeletableRow } from '../collectionRow'
|
||||
|
||||
/**
|
||||
* Tests de `removeCollectionRow` — suppression d'une ligne de collection
|
||||
* (contact / adresse / RIB) avec DELETE immediat de la sous-ressource existante
|
||||
* (ERP-172). Coeur de logique mutualise par les 3 modules (Client / Fournisseur /
|
||||
* Prestataire) : un seul comportement teste ici couvre les 9 cas (3 modules x 3
|
||||
* blocs).
|
||||
*/
|
||||
interface Row extends DeletableRow {
|
||||
label?: string
|
||||
}
|
||||
|
||||
function makeEmpty(): Row {
|
||||
return { id: null, label: '' }
|
||||
}
|
||||
|
||||
describe('removeCollectionRow', () => {
|
||||
it('emet un DELETE sur la sous-ressource quand le bloc est existant (id non null)', async () => {
|
||||
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
|
||||
const errors: Record<string, string>[] = [{}, {}]
|
||||
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||
const onError = vi.fn()
|
||||
|
||||
const removed = await removeCollectionRow({
|
||||
rows, errors, index: 0,
|
||||
endpoint: '/client_contacts',
|
||||
deleteRow, makeEmpty, onError,
|
||||
})
|
||||
|
||||
expect(deleteRow).toHaveBeenCalledOnce()
|
||||
expect(deleteRow).toHaveBeenCalledWith('/client_contacts/10')
|
||||
expect(removed).toBe(true)
|
||||
expect(rows).toEqual([{ id: 11, label: 'B' }])
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ne fait AUCUN appel reseau pour un bloc jamais persiste (id null) — retrait local', async () => {
|
||||
const rows: Row[] = [{ id: 10, label: 'A' }, { id: null, label: 'brouillon' }]
|
||||
const errors: Record<string, string>[] = [{}, {}]
|
||||
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||
const onError = vi.fn()
|
||||
|
||||
const removed = await removeCollectionRow({
|
||||
rows, errors, index: 1,
|
||||
endpoint: '/client_contacts',
|
||||
deleteRow, makeEmpty, onError,
|
||||
})
|
||||
|
||||
expect(deleteRow).not.toHaveBeenCalled()
|
||||
expect(removed).toBe(true)
|
||||
expect(rows).toEqual([{ id: 10, label: 'A' }])
|
||||
})
|
||||
|
||||
it('conserve le bloc et remonte l\'erreur si le DELETE serveur echoue (ex. 409 dernier RIB LCR)', async () => {
|
||||
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
|
||||
const errors: Record<string, string>[] = [{}, {}]
|
||||
const error = { response: { status: 409 } }
|
||||
const deleteRow = vi.fn().mockRejectedValue(error)
|
||||
const onError = vi.fn()
|
||||
|
||||
const removed = await removeCollectionRow({
|
||||
rows, errors, index: 0,
|
||||
endpoint: '/client_ribs',
|
||||
deleteRow, makeEmpty, onError,
|
||||
})
|
||||
|
||||
expect(removed).toBe(false)
|
||||
expect(onError).toHaveBeenCalledWith(error)
|
||||
// Bloc NON retire : la suppression n'a pas ete confirmee par le serveur.
|
||||
expect(rows).toEqual([{ id: 10, label: 'A' }, { id: 11, label: 'B' }])
|
||||
expect(errors).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('garde au moins un bloc visible apres retrait du dernier (amorce vide)', async () => {
|
||||
const rows: Row[] = [{ id: 10, label: 'A' }]
|
||||
const errors: Record<string, string>[] = [{}]
|
||||
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await removeCollectionRow({
|
||||
rows, errors, index: 0,
|
||||
endpoint: '/client_contacts',
|
||||
deleteRow, makeEmpty, onError: vi.fn(),
|
||||
})
|
||||
|
||||
expect(rows).toEqual([{ id: null, label: '' }])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests de `isRowRemovable` — la poubelle d'un bloc n'apparait que s'il reste un
|
||||
* AUTRE bloc deja enregistre (id en base). Empeche de supprimer un bloc tant que
|
||||
* rien n'est sauvegarde, et de supprimer son dernier bloc enregistre (ERP-172).
|
||||
*/
|
||||
describe('isRowRemovable', () => {
|
||||
it('faux quand aucun autre bloc n\'est enregistre (que des brouillons)', () => {
|
||||
const rows: Row[] = [{ id: null, label: 'brouillon 1' }, { id: null, label: 'brouillon 2' }]
|
||||
expect(isRowRemovable(rows, 0)).toBe(false)
|
||||
expect(isRowRemovable(rows, 1)).toBe(false)
|
||||
})
|
||||
|
||||
it('faux pour le seul bloc enregistre (un brouillon a cote ne compte pas)', () => {
|
||||
const rows: Row[] = [{ id: 10, label: 'enregistre' }, { id: null, label: 'brouillon' }]
|
||||
// Le bloc enregistre ne peut pas etre supprime : aucun AUTRE bloc enregistre.
|
||||
expect(isRowRemovable(rows, 0)).toBe(false)
|
||||
// Le brouillon peut etre jete : il reste le bloc enregistre id=10.
|
||||
expect(isRowRemovable(rows, 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('vrai pour chaque bloc des qu\'au moins deux sont enregistres', () => {
|
||||
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
|
||||
expect(isRowRemovable(rows, 0)).toBe(true)
|
||||
expect(isRowRemovable(rows, 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('faux pour un unique bloc', () => {
|
||||
expect(isRowRemovable([{ id: 10, label: 'A' }], 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
/** Ligne de collection supprimable (contact / adresse / RIB). */
|
||||
export interface DeletableRow {
|
||||
id?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Indique si le bloc d'index `index` peut afficher sa poubelle (ERP-172).
|
||||
*
|
||||
* Regle metier : on ne peut supprimer un bloc QUE s'il reste au moins un AUTRE
|
||||
* bloc deja enregistre (`id` non null, donc persiste en base). Consequences :
|
||||
* - tant que rien n'est enregistre -> aucune poubelle (pas de suppression d'un
|
||||
* simple brouillon saisi mais pas valide) ;
|
||||
* - on peut jeter un brouillon non enregistre s'il reste un bloc enregistre ;
|
||||
* - on ne peut jamais supprimer son dernier bloc enregistre.
|
||||
*/
|
||||
export function isRowRemovable<T extends DeletableRow>(rows: T[], index: number): boolean {
|
||||
return rows.some((row, i) => i !== index && row.id != null)
|
||||
}
|
||||
|
||||
/** Options de {@link removeCollectionRow}. */
|
||||
export interface RemoveCollectionRowOptions<T extends DeletableRow> {
|
||||
/** Tableau reactif des brouillons (passer le `.value` de la ref). */
|
||||
rows: T[]
|
||||
/** Tableau reactif des erreurs par ligne, aligne sur l'index (passer le `.value`). */
|
||||
errors: Record<string, string>[]
|
||||
/** Index de la ligne a retirer. */
|
||||
index: number
|
||||
/** Endpoint de la sous-ressource SANS id (ex: '/client_contacts'). */
|
||||
endpoint: string
|
||||
/** Suppression serveur : DOIT rejeter en cas d'echec (ex: url => api.delete(url, {}, { toast: false })). */
|
||||
deleteRow: (url: string) => Promise<unknown>
|
||||
/** Fabrique d'un bloc vide pour garder au moins un bloc visible apres retrait. */
|
||||
makeEmpty: () => T
|
||||
/** Remontee d'erreur 409/422 mappee proprement (message back, pas de toast fourre-tout). */
|
||||
onError: (error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire une ligne de collection (contact / adresse / RIB) sur les ecrans de
|
||||
* MODIFICATION, avec DELETE immediat de la sous-ressource (ERP-172). Comportement
|
||||
* aligne sur les 3 modules (Client / Fournisseur / Prestataire) :
|
||||
*
|
||||
* - Bloc jamais persiste (`id` null) : simple retrait local, aucun appel reseau.
|
||||
* - Bloc existant (`id` non null) : DELETE `/endpoint/{id}` AVANT le retrait du
|
||||
* tableau. On ne retire le bloc QUE si le serveur a confirme — sinon le bloc
|
||||
* reste affiche et l'erreur est remontee via `onError` (ex. dernier RIB d'une
|
||||
* LCR -> 409 back, RG-x.08).
|
||||
*
|
||||
* Etat purement local : `rows`/`errors` sont les `.value` des refs (proxies
|
||||
* reactifs), le `splice` declenche donc la reactivite.
|
||||
*
|
||||
* @returns `true` si la ligne a ete retiree (suppression confirmee ou bloc local),
|
||||
* `false` si la suppression serveur a echoue (bloc conserve).
|
||||
*/
|
||||
export async function removeCollectionRow<T extends DeletableRow>(
|
||||
options: RemoveCollectionRowOptions<T>,
|
||||
): Promise<boolean> {
|
||||
const { rows, errors, index, endpoint, deleteRow, makeEmpty, onError } = options
|
||||
const removed = rows[index]
|
||||
|
||||
// Bloc existant : suppression serveur d'abord, retrait local seulement si OK.
|
||||
if (removed?.id != null) {
|
||||
try {
|
||||
await deleteRow(`${endpoint}/${removed.id}`)
|
||||
}
|
||||
catch (error) {
|
||||
onError(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
rows.splice(index, 1)
|
||||
errors.splice(index, 1)
|
||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||
if (rows.length === 0) {
|
||||
rows.push(makeEmpty())
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -84,17 +84,6 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
'commercial.suppliers.accounting.view',
|
||||
'commercial.suppliers.accounting.manage',
|
||||
'commercial.suppliers.archive',
|
||||
// Technique — Repertoire prestataires (M3, ERP-138). Meme logique que
|
||||
// clients/fournisseurs : mappe sur le persona "tout", pas de nouveau
|
||||
// persona (regle ABSOLUE n°7). user-full porte deja sites.bypass_scope,
|
||||
// donc il voit les prestataires de tous les sites (M3 § 2.13).
|
||||
// technique.providers.view n'ajoute pas de lien dans la section
|
||||
// Administration, donc expectedAdminLinks reste inchange.
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
'technique.providers.archive',
|
||||
],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||
},
|
||||
|
||||
@@ -250,22 +250,6 @@ sync-permissions:
|
||||
seed-rbac:
|
||||
$(SYMFONY_CONSOLE) --no-interaction app:seed-rbac
|
||||
|
||||
# Synchronise le referentiel des transporteurs QUALIMAT (ERP-39) : upsert sur
|
||||
# le SIRET + soft-delete des absents + journal. Idempotent (refresh complet),
|
||||
# prevu pour un cron quotidien.
|
||||
# Options : --dry-run (analyse sans ecriture), --file=<chemin.json> (source
|
||||
# locale au lieu de l'API), --ppp=<n> (taille de page API, defaut 10000).
|
||||
qualimat-sync:
|
||||
$(SYMFONY_CONSOLE) --no-interaction app:qualimat:sync
|
||||
|
||||
# Synchronise le referentiel des codes IDTF (ERP-149) depuis l'export Excel
|
||||
# icrt-idtf.com : upsert sur (schema, idtf_number) + soft-delete + journal.
|
||||
# Idempotent (refresh complet).
|
||||
# Options : --schema=road|water (defaut road), --dry-run (analyse sans
|
||||
# ecriture), --file=<chemin.xlsx> (source locale au lieu du telechargement).
|
||||
idtf-sync:
|
||||
$(SYMFONY_CONSOLE) --no-interaction app:idtf:sync
|
||||
|
||||
# Attention, supprime votre bdd local
|
||||
db-reset:
|
||||
$(DOCKER_COMPOSE) down -v
|
||||
|
||||
@@ -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 job_title 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 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/fonction/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/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). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
|
||||
$this->comment('provider_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).');
|
||||
$this->comment('provider_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (normalisation serveur).');
|
||||
$this->comment('provider_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).');
|
||||
$this->comment('provider_contact', 'email', 'Email du contact (lowercase serveur).');
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-149 (Module Transport) : referentiel des codes IDTF (regimes de nettoyage
|
||||
* transport).
|
||||
*
|
||||
* Tables alimentees par la commande `app:idtf:sync` (parsing de l'export Excel
|
||||
* icrt-idtf.com, upsert sur (schema, idtf_number) + soft-delete + journal).
|
||||
* Aucune FK cross-module : migration au namespace racine `DoctrineMigrations`.
|
||||
*/
|
||||
final class Version20260612160000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-149 : tables idtf_product + idtf_sync_log (referentiel codes IDTF, synchro console depuis l\'export Excel).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE idtf_product (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
idtf_number INTEGER NOT NULL,
|
||||
schema VARCHAR(8) NOT NULL,
|
||||
product_group VARCHAR(255) DEFAULT NULL,
|
||||
name TEXT NOT NULL,
|
||||
cleaning_regime VARCHAR(64) NOT NULL,
|
||||
important_requirements TEXT DEFAULT NULL,
|
||||
mandatory_date DATE DEFAULT NULL,
|
||||
related_products TEXT DEFAULT NULL,
|
||||
formula VARCHAR(255) DEFAULT NULL,
|
||||
eural_code VARCHAR(64) DEFAULT NULL,
|
||||
cas_numbers JSONB DEFAULT '[]' NOT NULL,
|
||||
footnotes TEXT DEFAULT NULL,
|
||||
source_export_date DATE NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
last_synced_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT uq_idtf_product_schema_number UNIQUE (schema, idtf_number),
|
||||
CONSTRAINT chk_idtf_product_schema CHECK (schema IN ('road', 'water'))
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_idtf_product_active ON idtf_product (schema, is_active)');
|
||||
|
||||
$this->comment('idtf_product', '_table', "Referentiel des codes IDTF (marchandise + regime de nettoyage transport), synchronise depuis l'export Excel icrt-idtf.com.");
|
||||
$this->comment('idtf_product', 'id', 'Cle technique auto-incrementee.');
|
||||
$this->comment('idtf_product', 'idtf_number', 'Numero IDTF de la marchandise (identifiant metier source). Unique par schema.');
|
||||
$this->comment('idtf_product', 'schema', "Mode de transport / schema IDTF : 'road' (routier) ou 'water' (fluvial). Discriminant d'unicite avec idtf_number.");
|
||||
$this->comment('idtf_product', 'product_group', "Groupe de produit (colonne Product Group de l'export). Nullable.");
|
||||
$this->comment('idtf_product', 'name', "Nom de la marchandise (libelle FR de l'export).");
|
||||
$this->comment('idtf_product', 'cleaning_regime', 'Regime de nettoyage minimal exige (A, B, C, Interdit, ...).');
|
||||
$this->comment('idtf_product', 'important_requirements', 'Exigences importantes associees. Nullable.');
|
||||
$this->comment('idtf_product', 'mandatory_date', "Date d'application obligatoire du regime (convertie depuis dd-mm-yyyy). Nullable.");
|
||||
$this->comment('idtf_product', 'related_products', 'Produits apparentes (texte libre). Nullable.');
|
||||
$this->comment('idtf_product', 'formula', 'Formule chimique de la marchandise. Nullable.');
|
||||
$this->comment('idtf_product', 'eural_code', 'Code EURAL (dechet) associe. Nullable.');
|
||||
$this->comment('idtf_product', 'cas_numbers', 'Liste des numeros CAS (JSONB), eclatee depuis la cellule "Numero CAS" separee par ";". Tableau vide si absent.');
|
||||
$this->comment('idtf_product', 'footnotes', "Annotations / notes de bas de page de l'export. Nullable.");
|
||||
$this->comment('idtf_product', 'source_export_date', 'Date d\'export du fichier source (preambule "Export date:").');
|
||||
$this->comment('idtf_product', 'is_active', 'Faux = ligne absente du dernier export (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.');
|
||||
$this->comment('idtf_product', 'last_synced_at', 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE idtf_sync_log (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
schema VARCHAR(8) NOT NULL,
|
||||
export_date DATE NOT NULL,
|
||||
rows_total INT NOT NULL,
|
||||
rows_upserted INT NOT NULL,
|
||||
rows_deactivated INT NOT NULL,
|
||||
created_at TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW() NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->comment('idtf_sync_log', '_table', 'Journal des synchronisations IDTF (une ligne par run de la commande app:idtf:sync).');
|
||||
$this->comment('idtf_sync_log', 'id', 'Cle technique auto-incrementee.');
|
||||
$this->comment('idtf_sync_log', 'schema', "Mode de transport synchronise : 'road' ou 'water'.");
|
||||
$this->comment('idtf_sync_log', 'export_date', "Date d'export du fichier source traite par ce run.");
|
||||
$this->comment('idtf_sync_log', 'rows_total', 'Nombre de lignes exploitables lues dans le fichier.');
|
||||
$this->comment('idtf_sync_log', 'rows_upserted', 'Nombre de lignes inserees ou mises a jour.');
|
||||
$this->comment('idtf_sync_log', 'rows_deactivated', 'Nombre de lignes passees a is_active=false (absentes de cet export).');
|
||||
$this->comment('idtf_sync_log', 'created_at', 'Horodatage de fin du run (insertion du journal).');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS idtf_sync_log');
|
||||
$this->addSql('DROP TABLE IF EXISTS idtf_product');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pose un COMMENT ON TABLE/COLUMN en dollar-quoting Postgres ($_$...$_$)
|
||||
* pour eviter tout echappement d'apostrophes dans les descriptions.
|
||||
*/
|
||||
private function comment(string $table, string $column, string $description): void
|
||||
{
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?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).$_$');
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-154 — Infra d'upload de fichiers generique et reutilisable (src/Shared).
|
||||
*
|
||||
* Cree la table `uploaded_document` : reference technique d'un fichier televerse
|
||||
* (PDF / image), gere par le service Shared\Infrastructure\Upload\FileUploader.
|
||||
* La « Decharge » du M4 transporteurs en sera le premier consommateur, mais ce
|
||||
* ticket ne touche AUCUN module : la table vit cote Shared.
|
||||
*
|
||||
* Caracteristiques :
|
||||
* - Document IMMUABLE : pas d'onglet edition, pas de updated_at / updated_by.
|
||||
* Seules les colonnes created_at (UTC, remplie par le FileUploader via
|
||||
* l'horloge injectee) et created_by (auteur HTTP, null hors HTTP) tracent
|
||||
* l'origine. C'est pourquoi l'entite Shared n'implemente PAS
|
||||
* Timestampable/Blamable (qui imposeraient les 4 colonnes).
|
||||
* - checksum sha256 (64 caracteres hex) : controle d'integrite + future
|
||||
* deduplication eventuelle (hors scope ici).
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et non
|
||||
* modulaire : la table porte une FK cross-module vers "user" (created_by). Le
|
||||
* tri par version au sein du namespace racine garantit qu'elle joue APRES la
|
||||
* creation de "user" sur base vide.
|
||||
*
|
||||
* Style DDL aligne sur le M1/M2/M3 : `INT GENERATED BY DEFAULT AS IDENTITY` et
|
||||
* `TIMESTAMP(0) WITHOUT TIME ZONE` (mapping ORM `datetime_immutable`), pour que
|
||||
* `schema:update --force` reste un no-op une fois l'entite mappee.
|
||||
*
|
||||
* COMMENT ON COLUMN inline (regle ABSOLUE n°12) : chaque colonne porte sa
|
||||
* description ici. La table est aussi ajoutee a `ColumnCommentsCatalog` car
|
||||
* l'entite UploadedDocument existe des ce ticket — `app:apply-column-comments`
|
||||
* du `test-db-setup` rejoue donc ces COMMENT apres le `schema:update --force`.
|
||||
*/
|
||||
final class Version20260615130000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-154 : table uploaded_document (infra upload generique Shared) — fichier televerse immuable, checksum sha256.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE uploaded_document (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
original_filename VARCHAR(255) NOT NULL,
|
||||
stored_path VARCHAR(512) NOT NULL,
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
size_bytes INT NOT NULL,
|
||||
checksum VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_uploaded_document_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
// Postgres n'indexe pas automatiquement les colonnes de FK.
|
||||
$this->addSql('CREATE INDEX idx_uploaded_document_created_by ON uploaded_document (created_by)');
|
||||
// Recherche d'integrite / future deduplication par empreinte sha256.
|
||||
$this->addSql('CREATE INDEX idx_uploaded_document_checksum ON uploaded_document (checksum)');
|
||||
|
||||
$this->addSql('COMMENT ON TABLE uploaded_document IS $_$Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.id IS $_$Identifiant interne auto-incremente.$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.original_filename IS $_$Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.stored_path IS $_$Chemin relatif du fichier sous var/uploads (ex: 2026/06/<hash>.pdf) — nom genere aleatoirement, jamais le nom client.$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.mime_type IS $_$Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.size_bytes IS $_$Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.checksum IS $_$Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.created_at IS $_$Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.created_by IS $_$ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.$_$');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS uploaded_document');
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,7 @@ 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) ;
|
||||
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
|
||||
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -49,15 +48,15 @@ class Bank
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
|
||||
@@ -21,8 +21,7 @@ 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) ;
|
||||
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
|
||||
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -49,15 +48,15 @@ class PaymentDelay
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
|
||||
@@ -24,8 +24,7 @@ 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) ;
|
||||
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
|
||||
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -52,15 +51,15 @@ class PaymentType
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
|
||||
@@ -25,8 +25,7 @@ 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) ;
|
||||
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
|
||||
* fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0).
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -56,15 +55,15 @@ class TvaMode
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
|
||||
@@ -50,19 +50,11 @@ final class RbacSeeder
|
||||
/**
|
||||
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
|
||||
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
|
||||
* attacher (admin n'apparait pas car il bypass tout via isAdmin ;
|
||||
* `commercial.clients.archive`, `commercial.suppliers.archive` et
|
||||
* `technique.providers.archive` ne sont attaches a aucun role metier —
|
||||
* attacher (vide pour usine : aucun acces ; admin n'apparait pas car il
|
||||
* bypass tout via isAdmin ; `commercial.clients.archive` et
|
||||
* `commercial.suppliers.archive` ne sont attaches a aucun role metier —
|
||||
* admin seul).
|
||||
*
|
||||
* Cloisonnement par site des prestataires (M3 § 2.13) : la permission
|
||||
* `sites.bypass_scope` est attribuee par defaut a Bureau / Compta /
|
||||
* Commerciale (ils voient « Tout », d'apres le docx) ; Usine ne l'a PAS et
|
||||
* reste cloisonnee a son site courant. Admin a le bypass total via isAdmin.
|
||||
* C'est un cloisonnement pilote par user/permission, pas par code de role :
|
||||
* pour cloisonner Bureau/Commerciale, il suffit de retirer la permission
|
||||
* ici, aucun autre code a changer.
|
||||
*
|
||||
* @var array<string, array{label: string, permissions: list<string>}>
|
||||
*/
|
||||
private const array MATRIX = [
|
||||
@@ -74,11 +66,6 @@ final class RbacSeeder
|
||||
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
|
||||
'commercial.suppliers.view',
|
||||
'commercial.suppliers.manage',
|
||||
// Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite).
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
|
||||
'sites.bypass_scope',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
'catalog.categories.read_ref',
|
||||
'sites.read_ref',
|
||||
@@ -95,13 +82,6 @@ final class RbacSeeder
|
||||
'commercial.suppliers.view',
|
||||
'commercial.suppliers.accounting.view',
|
||||
'commercial.suppliers.accounting.manage',
|
||||
// Prestataires (M3 § 2.9, ERP-138) : view + onglet Comptabilite uniquement
|
||||
// (pas de manage global -> ne peut pas creer un prestataire).
|
||||
'technique.providers.view',
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
|
||||
'sites.bypass_scope',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
'catalog.categories.read_ref',
|
||||
'sites.read_ref',
|
||||
@@ -116,25 +96,14 @@ final class RbacSeeder
|
||||
// (onglet Comptabilite masque/filtre pour la Commerciale).
|
||||
'commercial.suppliers.view',
|
||||
'commercial.suppliers.manage',
|
||||
// Prestataires (M3 § 2.9, ERP-138) : view + manage, sans accounting
|
||||
// (onglet Comptabilite masque/filtre pour la Commerciale).
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
|
||||
'sites.bypass_scope',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
'catalog.categories.read_ref',
|
||||
'sites.read_ref',
|
||||
],
|
||||
],
|
||||
self::ROLE_USINE => [
|
||||
'label' => 'Usine',
|
||||
// Prestataires (M3 § 2.9 + § 2.13, ERP-138) : view en lecture seule,
|
||||
// SANS `sites.bypass_scope` -> cloisonne aux prestataires de son site
|
||||
// courant. Aucun autre acces metier.
|
||||
'permissions' => [
|
||||
'technique.providers.view',
|
||||
],
|
||||
'label' => 'Usine',
|
||||
'permissions' => [],
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -203,15 +203,6 @@ final class SeedE2ECommand extends Command
|
||||
'commercial.suppliers.accounting.view',
|
||||
'commercial.suppliers.accounting.manage',
|
||||
'commercial.suppliers.archive',
|
||||
// Technique — Repertoire prestataires (M3, ERP-138). Meme
|
||||
// logique : mappe sur le persona "tout". user-full porte deja
|
||||
// sites.bypass_scope -> voit les prestataires de tous les
|
||||
// sites (M3 § 2.13). Miroir de personas.ts.
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
'technique.providers.archive',
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Application\Service;
|
||||
|
||||
/**
|
||||
* Normalisation serveur des champs texte d'un Provider / ProviderContact,
|
||||
* appliquee par le ProviderProcessor (et les processors de sous-ressources,
|
||||
* ticket ulterieur M3) AVANT persistance. Cf. spec-back M3 § 2.11 + RG-3.11.
|
||||
* Jumeau de SupplierFieldNormalizer (M2) — duplique volontairement (isolation
|
||||
* Commercial / Technique, decision § 2.1).
|
||||
*
|
||||
* - companyName : UPPERCASE integral (RG-3.11)
|
||||
* - firstName / lastName (personnes, sur ProviderContact) : Title Case (RG-3.11)
|
||||
* - phone* : chiffres uniquement, ex "06.12.34.56.78" -> "0612345678" (RG-3.11).
|
||||
* Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front.
|
||||
* - email : lowercase integral (RG-3.11)
|
||||
*
|
||||
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide
|
||||
* apres trim devient null (evite de persister "" dans des colonnes nullable).
|
||||
*/
|
||||
final class ProviderFieldNormalizer
|
||||
{
|
||||
/**
|
||||
* Nom de societe en majuscules (RG-3.11). Conserve null tel quel ; une
|
||||
* chaine non vide est trim + upper. Une chaine vide reste "" (champ
|
||||
* obligatoire : c'est l'Assert\NotBlank qui rejette, pas le normalizer).
|
||||
*/
|
||||
public function normalizeCompanyName(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mb_strtoupper(trim($value), 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nom/prenom de personne en Title Case (RG-3.11) : "JEAN dupont" ->
|
||||
* "Jean Dupont". Une chaine vide apres trim devient null.
|
||||
*/
|
||||
public function normalizePersonName(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Email en minuscules (RG-3.11). Une chaine vide apres trim devient null.
|
||||
*/
|
||||
public function normalizeEmail(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
public function normalizePhone(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D+/', '', $value) ?? '';
|
||||
|
||||
return '' === $digits ? null : $digits;
|
||||
}
|
||||
}
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,6 @@ use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||
use App\Module\Commercial\Domain\Entity\TvaMode;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderProcessor;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderProvider;
|
||||
use App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
@@ -55,12 +53,11 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
* reference de donnees de reference, pas de logique inter-module.
|
||||
*
|
||||
* Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) : les read-groups
|
||||
* sont poses ICI (source unique). L'#[ApiResource] cable (ERP-134) le ProviderProvider
|
||||
* (liste paginee anti-N+1, exclusion archives, cloisonnement site lecture + detail
|
||||
* 404) et le ProviderProcessor (normalisation, archivage, mode strict par groupe,
|
||||
* cloisonnement site ecriture, 409 doublon). Le groupe provider:read:accounting est
|
||||
* ajoute dynamiquement au contexte par le ProviderReadGroupContextBuilder selon la
|
||||
* permission accounting.view (ERP-134) — jamais pose en dur sur l'operation.
|
||||
* sont poses ICI (source unique). L'#[ApiResource] est ici un SQUELETTE (operations
|
||||
* + contextes + security) ; le ProviderProvider (liste paginee anti-N+1, exclusion
|
||||
* archives, cloisonnement site, gating accounting) et le ProviderProcessor
|
||||
* (normalisation, archivage, 409 doublon, RG-3.07 / RG-3.08) sont cables au ticket
|
||||
* suivant (ERP-134) — ils ne sont volontairement PAS references ici.
|
||||
*
|
||||
* Audite (#[Auditable], tous champs — y compris RIB embarques, § 2.7) +
|
||||
* Timestampable / Blamable via le trait Shared.
|
||||
@@ -72,18 +69,17 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
// La liste embarque les categories (code/name, groupe category:read) et
|
||||
// les sites du prestataire (name/postalCode, groupe site:read — relation
|
||||
// DIRECTE provider.sites, RG-3.03). Maillon (c) : category:read +
|
||||
// site:read presents dans le contexte. Hydratation anti-N+1 cablee par
|
||||
// le ProviderProvider (cf. DoctrineProviderRepository::hydrateListCollections).
|
||||
// site:read presents dans le contexte. L'hydratation anti-N+1 sera
|
||||
// cablee par le ProviderProvider (ERP-134, cf. DoctrineProviderRepository).
|
||||
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
|
||||
provider: ProviderProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('technique.providers.view')",
|
||||
// Detail : prestataire + sous-collections embarquees (contacts, adresses
|
||||
// + leurs sites/categories/contacts) + RIB (gates compta). Le groupe
|
||||
// provider:read:accounting est volontairement ABSENT : il est ajoute au
|
||||
// contexte par le ProviderReadGroupContextBuilder selon la permission
|
||||
// accounting.view (parade fuite IBAN/BIC — bug #4 M1).
|
||||
// provider:read:accounting est volontairement ABSENT : il sera ajoute au
|
||||
// contexte par le ProviderProvider / ReadGroupContextBuilder selon la
|
||||
// permission accounting.view (ERP-134, parade fuite IBAN/BIC — bug #4 M1).
|
||||
normalizationContext: ['groups' => [
|
||||
'provider:read',
|
||||
'provider:item:read',
|
||||
@@ -91,13 +87,11 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
'site:read',
|
||||
'default:read',
|
||||
]],
|
||||
provider: ProviderProvider::class,
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['provider:write:main']],
|
||||
processor: ProviderProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
// Security elargie : `manage` OU `accounting.manage` — le role Compta n'a
|
||||
@@ -111,8 +105,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
'provider:write:accounting',
|
||||
'provider:write:archive',
|
||||
]],
|
||||
provider: ProviderProvider::class,
|
||||
processor: ProviderProcessor::class,
|
||||
),
|
||||
// Pas de Delete au M3 (HP M4). Archivage via PATCH { isArchived: true }.
|
||||
],
|
||||
@@ -140,12 +132,6 @@ 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]
|
||||
@@ -297,44 +283,6 @@ 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,14 +4,6 @@ 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;
|
||||
@@ -40,64 +32,16 @@ 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)).
|
||||
*
|
||||
* 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).
|
||||
* maillon (a)). L'exposition en SOUS-RESSOURCE API est un ticket ulterieur du M3 :
|
||||
* pas d'#[ApiResource] ici.
|
||||
*
|
||||
* 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, ProviderOwnedInterface
|
||||
class ProviderAddress implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ 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;
|
||||
@@ -23,68 +15,24 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
/**
|
||||
* Contact d'un prestataire (1:n) — onglet Contacts. Un bloc est valide des qu'au
|
||||
* moins un champ est rempli (RG-3.04) : garantie portee par un CHECK BDD
|
||||
* (chk_provider_contact_name) + le ProviderContactProcessor (ERP-135) ; l'entite
|
||||
* reste permissive (tous les champs nullable).
|
||||
* (chk_provider_contact_name) + le ProviderProcessor (ERP-134) ; l'entite reste
|
||||
* permissive (tous les champs nullable).
|
||||
*
|
||||
* Embarque sous `provider.contacts` au detail (groupe provider:item:read,
|
||||
* maillon (a) du contrat de serialisation). Maximum 2 telephones
|
||||
* (phonePrimary + phoneSecondary).
|
||||
*
|
||||
* Sous-ressource API (ERP-135, spec § 4.5) :
|
||||
* - POST /api/providers/{providerId}/contacts : creation rattachee au prestataire
|
||||
* parent (Link toProperty 'provider'), security technique.providers.manage.
|
||||
* - PATCH / DELETE /api/provider_contacts/{id} : security technique.providers.manage.
|
||||
* Le DELETE est physique et libre (pas de garde « dernier contact » au M3 —
|
||||
* RG-3.12 front-driven, la collection peut rester vide cote back).
|
||||
* - GET /api/provider_contacts/{id} : lecture unitaire (security view) — la lecture
|
||||
* courante reste via le parent (le prestataire embarque ses contacts). Pas de GET
|
||||
* collection autonome.
|
||||
* Tout passe par le ProviderContactProcessor (normalisation RG-3.11, RG-3.04).
|
||||
* 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).
|
||||
*
|
||||
* 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, ProviderOwnedInterface
|
||||
class ProviderContact implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?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,14 +4,6 @@ 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;
|
||||
@@ -23,7 +15,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
|
||||
* ProviderRibProcessor : refus du DELETE du dernier RIB sous LCR — ERP-135).
|
||||
* ProviderProcessor : refus du DELETE du dernier RIB sous LCR — ERP-134).
|
||||
*
|
||||
* Embarque sous `provider.ribs` UNIQUEMENT si l'user a accounting.view : le
|
||||
* read-group est `provider:read:accounting`, retire du contexte par le
|
||||
@@ -31,62 +23,19 @@ 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).
|
||||
*
|
||||
* 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).
|
||||
* 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.
|
||||
*
|
||||
* 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, ProviderOwnedInterface
|
||||
class ProviderRib implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
|
||||
@@ -13,21 +13,6 @@ interface ProviderRepositoryInterface
|
||||
|
||||
public function save(Provider $provider): void;
|
||||
|
||||
/**
|
||||
* Restreint un QueryBuilder de liste aux prestataires rattaches au site donne
|
||||
* (relation DIRECTE provider.sites). Sert le cloisonnement par site pilote par
|
||||
* l'utilisateur (RG-3.17, § 2.13) : le ProviderProvider resout le site courant
|
||||
* (CurrentSiteProvider) puis appelle cette methode quand l'user n'a pas
|
||||
* `sites.bypass_scope`. Decouple ainsi la DECISION (Provider, qui connait
|
||||
* l'user) du DQL (repository, qui ne connait que l'id de site).
|
||||
*
|
||||
* Sous-requete IN (et non JOIN sur la M2M) pour ne pas perturber le
|
||||
* DISTINCT / ORDER BY / pagination du QueryBuilder de selection — meme parti
|
||||
* pris que les filtres ?categoryCode / ?siteId. Applique AVANT la pagination
|
||||
* (le COUNT du Paginator reflete alors le perimetre de l'user).
|
||||
*/
|
||||
public function applySiteScope(QueryBuilder $qb, int $siteId): void;
|
||||
|
||||
/**
|
||||
* Construit un QueryBuilder de liste pour le repertoire prestataires.
|
||||
* - Exclut toujours les prestataires soft-deletes (deleted_at IS NOT NULL, RG-3.16).
|
||||
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\Serializer;
|
||||
|
||||
use ApiPlatform\State\SerializerContextBuilderInterface;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Decore le context builder de serialisation d'API Platform pour ajouter
|
||||
* DYNAMIQUEMENT le groupe de lecture `provider:read:accounting` sur les
|
||||
* ressources Provider, uniquement si l'utilisateur courant a la permission
|
||||
* `technique.providers.accounting.view` (cf. spec-back M3 § 2.9 / § 4.1 /
|
||||
* § 4.2). Jumeau de SupplierReadGroupContextBuilder (M2).
|
||||
*
|
||||
* Pourquoi un context builder et pas le Provider : un Provider retourne des
|
||||
* donnees mais ne peut pas influencer les groupes de serialisation. Le contexte
|
||||
* de normalisation est construit ici, en amont du serializer — c'est le point
|
||||
* d'extension idiomatique d'API Platform pour conditionner un groupe selon
|
||||
* l'utilisateur. Realise l'intention « gating du groupe accounting » de la spec
|
||||
* (le groupe n'est jamais pose par defaut sur l'operation : il est AJOUTE ici si
|
||||
* la permission est presente — resultat identique au « retrait » decrit en spec).
|
||||
*
|
||||
* S'applique aux operations de LECTURE (normalization) sur Provider : liste ET
|
||||
* detail. Sans la permission, les champs comptables (siren, accountNumber,
|
||||
* tvaMode, nTva, paymentDelay, paymentType, bank) ET les RIB embarques (groupe
|
||||
* provider:read:accounting porte par getRibs()) ne sont jamais serialises — la
|
||||
* cle est totalement absente du JSON (gating par omission, parade bug #4 M1).
|
||||
*
|
||||
* Priorite de decoration -20 : on s'empile APRES les decorateurs Commercial
|
||||
* (ClientReadGroupContextBuilder priorite 0, SupplierReadGroupContextBuilder
|
||||
* priorite -10) sur le meme service `api_platform.serializer.context_builder`.
|
||||
* Chaque decorateur passe la main pour toute ressource autre que la sienne :
|
||||
* l'ordre de chainage n'a donc aucun effet fonctionnel, la priorite explicite ne
|
||||
* sert qu'a lever l'ambiguite de plusieurs decorateurs sur un meme service.
|
||||
*/
|
||||
#[AsDecorator(decorates: 'api_platform.serializer.context_builder', priority: -20)]
|
||||
final readonly class ProviderReadGroupContextBuilder implements SerializerContextBuilderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[AutowireDecorated]
|
||||
private SerializerContextBuilderInterface $decorated,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
|
||||
{
|
||||
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
|
||||
|
||||
// Uniquement en lecture, sur la ressource Provider, avec la permission.
|
||||
if (!$normalization) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
if (Provider::class !== ($context['resource_class'] ?? null)) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted('technique.providers.accounting.view')) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
$groups = $context['groups'] ?? [];
|
||||
if (!in_array('provider:read:accounting', $groups, true)) {
|
||||
$groups[] = 'provider:read:accounting';
|
||||
}
|
||||
$context['groups'] = $groups;
|
||||
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
-220
@@ -1,220 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Entity\ProviderAddress;
|
||||
use App\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
@@ -1,144 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Entity\ProviderContact;
|
||||
use 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-590
@@ -1,590 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
||||
use App\Module\Technique\Application\Validator\ProviderAccountingCompletenessValidator;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\PersistentCollection;
|
||||
use JsonException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture du repertoire prestataires (M3). Cf. spec-back M3 § 4.3 /
|
||||
* § 4.4 + RG-3.10 / RG-3.13 / RG-3.14 / RG-3.15 / RG-3.17. Jumeau du
|
||||
* SupplierProcessor (M2), avec deux differences structurantes (§ 3.1) :
|
||||
* - PAS d'onglet Information (aucun champ description / competitors / ...) ni de
|
||||
* validation de completude comptable -> le prestataire est minimal ;
|
||||
* - le formulaire principal porte `sites` (M2M provider_site, RG-3.03), soumis au
|
||||
* CLOISONNEMENT D'ECRITURE par site (RG-3.17, § 2.13) : un user sans
|
||||
* `sites.bypass_scope` ne peut attacher que les sites de ses `user_site`.
|
||||
*
|
||||
* Sequence (POST / PATCH) :
|
||||
* 1. Autorisation additionnelle par groupe d'onglet (mode strict RG-3.15). La
|
||||
* security d'operation du PATCH est elargie a `manage` OU `accounting.manage`
|
||||
* pour laisser entrer le role Compta ; ce processor re-gate alors finement :
|
||||
* - champ comptable modifie dans le payload -> exige accounting.manage (403) ;
|
||||
* - champ main (companyName / categories / sites) modifie -> exige manage
|
||||
* (guardManage, 403) : empeche Compta d'editer un autre onglet ;
|
||||
* - champ isArchived dans le payload -> exige archive (RG-3.13, 403) et
|
||||
* interdit toute autre modification dans la meme requete (RG-3.13, 422).
|
||||
* 2. Cloisonnement d'ECRITURE des sites (RG-3.17 / RG-3.03) : tout site attache
|
||||
* hors des `user_site` de l'appelant non-bypass -> 422 sur `sites`.
|
||||
* 3. Normalisation serveur (RG-3.11) via ProviderFieldNormalizer (companyName).
|
||||
* 4. Pose / retrait de archivedAt (RG-3.13 true=now, RG-3.14 false=null).
|
||||
* 5. Persistance via le persist_processor Doctrine, avec traduction des
|
||||
* collisions d'unicite en 409 (RG-3.10 doublon de nom ; RG-3.14 conflit de
|
||||
* restauration).
|
||||
*
|
||||
* Les RG inter-champs RG-3.07 (Virement -> banque), RG-3.08 (LCR -> >= 1 RIB) et
|
||||
* RG-3.09 (categorie de type PRESTATAIRE) sont portees par des Assert\Callback +
|
||||
* ->atPath() sur les entites Provider / ProviderAddress (jouees par API Platform
|
||||
* AVANT ce processor), pour que chaque 422 porte un propertyPath consommable par
|
||||
* extractApiViolations (mapping inline, pas un toast — convention ERP-101). Le 409
|
||||
* sur DELETE du dernier RIB en LCR (volet ecriture de RG-3.08) est porte par le
|
||||
* ProviderRibProcessor (ERP-135).
|
||||
*
|
||||
* @implements ProcessorInterface<Provider, Provider>
|
||||
*/
|
||||
final class ProviderProcessor implements ProcessorInterface
|
||||
{
|
||||
/** Champs de l'onglet principal (groupe provider:write:main). */
|
||||
private const array MAIN_FIELDS = [
|
||||
'companyName', 'categories', 'sites',
|
||||
];
|
||||
|
||||
/** Champs de l'onglet Comptabilite (groupe provider:write:accounting). */
|
||||
private const array ACCOUNTING_FIELDS = [
|
||||
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay',
|
||||
'paymentType', 'bank',
|
||||
];
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
private const string PERM_MANAGE = 'technique.providers.manage';
|
||||
private const string PERM_ACCOUNTING_MANAGE = 'technique.providers.accounting.manage';
|
||||
private const string PERM_ARCHIVE = 'technique.providers.archive';
|
||||
|
||||
private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope';
|
||||
|
||||
/**
|
||||
* Memoisation du dernier corps de requete decode, clos par le contenu brut
|
||||
* (cf. SupplierProcessor) : payloadKeys() est appele plusieurs fois par requete,
|
||||
* on evite de rejouer json_decode. Cle = contenu lui-meme, calcul pur -> aucune
|
||||
* fuite entre requetes sur ce service partage.
|
||||
*/
|
||||
private ?string $decodedContent = null;
|
||||
|
||||
/** @var list<string> Cles de premier niveau correspondant au corps memoise. */
|
||||
private array $decodedPayloadKeys = [];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly ProviderFieldNormalizer $normalizer,
|
||||
private readonly Security $security,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ProviderAccountingCompletenessValidator $accountingValidator,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof Provider) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
// Reinitialisation de la memoisation du payload : le service est partage
|
||||
// (stateful), on repart du corps de LA requete courante.
|
||||
$this->decodedContent = null;
|
||||
$this->decodedPayloadKeys = [];
|
||||
|
||||
$writableKeys = $this->writablePayloadKeys();
|
||||
|
||||
$isArchiveRequest = $this->guardArchive($data, $writableKeys);
|
||||
$this->guardAccounting($data);
|
||||
$this->guardSiteScope($data);
|
||||
|
||||
$this->normalize($data);
|
||||
|
||||
// guardManage apres normalize : la comparaison « change vs etat persiste »
|
||||
// des champs texte (companyName) se fait sur des valeurs normalisees des
|
||||
// deux cotes (l'etat persiste l'a deja ete).
|
||||
$this->guardManage($data);
|
||||
|
||||
// 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) {
|
||||
// Le seul index unique partiel est uq_provider_company_name_active
|
||||
// (LOWER(company_name) parmi non-archives/non-deletes — § 2.6).
|
||||
if ($isArchiveRequest && false === $data->isArchived()) {
|
||||
// RG-3.14 : restauration en conflit avec un homonyme actif.
|
||||
throw new ConflictHttpException(
|
||||
'Restauration impossible : un autre prestataire a pris le nom entre-temps.',
|
||||
$e,
|
||||
);
|
||||
}
|
||||
|
||||
// RG-3.10 : doublon de nom de societe.
|
||||
throw new ConflictHttpException(
|
||||
sprintf('Un prestataire nommé "%s" existe déjà.', (string) $data->getCompanyName()),
|
||||
$e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.13 / RG-3.14 : si le payload bascule reellement isArchived, exige la
|
||||
* permission archive (403), interdit toute autre modification (422) et
|
||||
* pose/retire archivedAt. Retourne true si la requete est une requete
|
||||
* d'archivage. Restreint a la mise a jour d'un prestataire existant ET au seul
|
||||
* cas ou isArchived change vraiment (cf. SupplierProcessor).
|
||||
*
|
||||
* @param list<string> $writableKeys cles ecrivables du payload (hors @* et champs inconnus)
|
||||
*/
|
||||
private function guardArchive(Provider $data, array $writableKeys): bool
|
||||
{
|
||||
// POST / entite non geree : l'archivage est une action de mise a jour.
|
||||
if (!$this->em->contains($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// isArchived inchange par rapport a l'etat persiste : pas une requete
|
||||
// d'archivage (cas du PATCH representation complete).
|
||||
if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted(self::PERM_ARCHIVE)) {
|
||||
throw new AccessDeniedHttpException(sprintf(
|
||||
'Le champ "%s" requiert la permission "%s".',
|
||||
self::ARCHIVE_FIELD,
|
||||
self::PERM_ARCHIVE,
|
||||
));
|
||||
}
|
||||
|
||||
// RG-3.13 : une requete d'archivage ne modifie aucun autre champ ecrivable.
|
||||
if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) {
|
||||
throw new UnprocessableEntityHttpException(
|
||||
'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".',
|
||||
);
|
||||
}
|
||||
|
||||
// RG-3.13 (true -> now) / RG-3.14 (false -> null).
|
||||
$data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.15 : la modification effective d'un champ comptable exige
|
||||
* accounting.manage, sinon 403 sur l'ensemble du payload (mode strict, pas de
|
||||
* filtrage silencieux). On ne gate que si un champ change reellement par
|
||||
* rapport a l'etat persiste (POST/PATCH renvoyant des champs comptables
|
||||
* inchanges ne declenche pas de 403 parasite). Le message precise le premier
|
||||
* champ fautif.
|
||||
*/
|
||||
private function guardAccounting(Provider $data): void
|
||||
{
|
||||
$changed = $this->changedAccountingFields($data);
|
||||
|
||||
if ([] === $changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) {
|
||||
throw new AccessDeniedHttpException(sprintf(
|
||||
'Le champ "%s" requiert la permission "%s".',
|
||||
$changed[0],
|
||||
self::PERM_ACCOUNTING_MANAGE,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* § 2.9 / RG-3.15 : la modification effective d'un champ « metier » (onglet
|
||||
* principal : companyName / categories / sites) exige
|
||||
* `technique.providers.manage`. Sans cette permission -> 403 sur l'ensemble du
|
||||
* payload (mode strict, miroir de guardAccounting). C'est ce qui empeche le
|
||||
* role Compta — qui entre dans le PATCH via `accounting.manage` (security
|
||||
* d'operation elargie) — d'editer autre chose que l'onglet Comptabilite.
|
||||
*
|
||||
* Ne s'applique qu'aux mises a jour (entite geree) : la creation (POST) est
|
||||
* deja gardee par la security d'operation `manage`.
|
||||
*/
|
||||
private function guardManage(Provider $data): void
|
||||
{
|
||||
if (!$this->em->contains($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$changed = $this->changedBusinessFields($data);
|
||||
|
||||
if ([] === $changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted(self::PERM_MANAGE)) {
|
||||
throw new AccessDeniedHttpException(sprintf(
|
||||
'Le champ "%s" requiert la permission "%s".',
|
||||
$changed[0],
|
||||
self::PERM_MANAGE,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.17 / RG-3.03 (cloisonnement d'ECRITURE — § 2.13) : un user SANS
|
||||
* `sites.bypass_scope` ne peut attacher au prestataire que des sites figurant
|
||||
* dans ses propres `user_site`. Tout site hors perimetre -> 422 sur `sites`
|
||||
* (propertyPath consommable inline, convention ERP-101). Un user `bypass_scope`
|
||||
* (Admin auto) peut attacher n'importe quel site.
|
||||
*
|
||||
* Interaction avec SiteCollectionScopedExtension (module Sites) : pour un user
|
||||
* sans `sites.bypass_scope` NI `sites.read_ref`, la resolution de l'IRI de site
|
||||
* hors perimetre echoue DEJA en amont (item Site « introuvable » -> 400
|
||||
* anti-enumeration), avant ce processor. Cette garde reste donc l'enforcement
|
||||
* AUTORITAIRE de RG-3.17 pour le cas particulier d'un user `sites.read_ref`
|
||||
* (qui peut resoudre N'IMPORTE quel site comme referentiel transverse mais ne
|
||||
* doit rattacher que ses propres sites), et une defense en profondeur sinon.
|
||||
*
|
||||
* Ne joue que si `sites` est effectivement soumis : POST (entite non geree,
|
||||
* sites obligatoires RG-3.03) ou PATCH portant la cle `sites`. Un PATCH qui ne
|
||||
* touche pas aux sites n'est pas re-valide (les sites ont ete cloisonnes a leur
|
||||
* pose). La validation porte sur l'ETAT RESULTANT (data.getSites()).
|
||||
*/
|
||||
private function guardSiteScope(Provider $data): void
|
||||
{
|
||||
if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// sites non soumis sur un PATCH : rien a cloisonner.
|
||||
if ($this->em->contains($data) && !in_array('sites', $this->payloadKeys(), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$allowedSiteIds = $this->currentUserSiteIds();
|
||||
|
||||
foreach ($data->getSites() as $site) {
|
||||
if (!$site instanceof SiteInterface) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($site->getId(), $allowedSiteIds, true)) {
|
||||
$this->throwSitesViolation($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifiants des sites rattaches a l'utilisateur courant (`user_site`).
|
||||
* Vide si pas d'user authentifie (cas defensif : la security d'operation
|
||||
* garantit deja l'authentification).
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function currentUserSiteIds(): array
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
foreach ($user->getSites() as $site) {
|
||||
if ($site instanceof SiteInterface && null !== $site->getId()) {
|
||||
$ids[] = $site->getId();
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs « metier » (onglet principal : companyName / categories / sites) dont
|
||||
* la valeur courante differe de l'etat persiste. Scalaires compares par valeur ;
|
||||
* collections M2M (categories / sites) comparees par ensemble d'identifiants
|
||||
* (cf. collectionChanged) — la simple presence dans le payload ne suffit pas,
|
||||
* sous peine de 403 parasite sur un PATCH representation complete.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function changedBusinessFields(Provider $data): array
|
||||
{
|
||||
$changed = [];
|
||||
|
||||
if ($this->fieldChanged($data, 'companyName', $data->getCompanyName())) {
|
||||
$changed[] = 'companyName';
|
||||
}
|
||||
|
||||
if ($this->collectionChanged($data, 'categories', $data->getCategories()->toArray())) {
|
||||
$changed[] = 'categories';
|
||||
}
|
||||
|
||||
if ($this->collectionChanged($data, 'sites', $data->getSites()->toArray())) {
|
||||
$changed[] = 'sites';
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si une collection M2M (`categories` ou `sites`) differe reellement de
|
||||
* l'etat persiste. Ces collections ne sont pas tracees par
|
||||
* getOriginalEntityData : on compare par identifiants (independamment de
|
||||
* l'ordre) le snapshot de la PersistentCollection (etat charge) a l'etat
|
||||
* courant (apres application du payload). Symetrique des scalaires : seul un
|
||||
* changement effectif compte, pas la simple presence dans le payload.
|
||||
*
|
||||
* - POST / entite non geree : fournir la collection est un acte metier
|
||||
* (branche defensive, guardManage ne s'execute que sur entite geree).
|
||||
* - cle absente du payload (PATCH partiel) : aucun changement.
|
||||
*
|
||||
* @param array<int, object> $current
|
||||
*/
|
||||
private function collectionChanged(Provider $data, string $field, array $current): bool
|
||||
{
|
||||
if (!$this->em->contains($data)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!in_array($field, $this->payloadKeys(), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$collection = 'categories' === $field ? $data->getCategories() : $data->getSites();
|
||||
|
||||
// Hors PersistentCollection (cas limite hors flux PATCH reel) : faute d'etat
|
||||
// persiste comparable, on se rabat sur la presence payload.
|
||||
if (!$collection instanceof PersistentCollection) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->idSet($current) !== $this->idSet($collection->getSnapshot());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensemble trie des identifiants d'une liste d'entites — pour une comparaison
|
||||
* par valeur independante de l'ordre.
|
||||
*
|
||||
* @param array<int, object> $entities
|
||||
*
|
||||
* @return list<mixed>
|
||||
*/
|
||||
private function idSet(array $entities): array
|
||||
{
|
||||
$ids = array_map(
|
||||
static fn (object $entity): mixed => method_exists($entity, 'getId')
|
||||
? $entity->getId()
|
||||
: spl_object_id($entity),
|
||||
array_values($entities),
|
||||
);
|
||||
sort($ids);
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs comptables dont la valeur courante differe de l'etat persiste. Les
|
||||
* relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par
|
||||
* identite d'objet : l'identity map Doctrine renvoie la meme instance tant que
|
||||
* la reference est inchangee.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function changedAccountingFields(Provider $data): array
|
||||
{
|
||||
$changed = [];
|
||||
|
||||
foreach (self::ACCOUNTING_FIELDS as $field) {
|
||||
$newValue = match ($field) {
|
||||
'siren' => $data->getSiren(),
|
||||
'accountNumber' => $data->getAccountNumber(),
|
||||
'tvaMode' => $data->getTvaMode(),
|
||||
'nTva' => $data->getNTva(),
|
||||
'paymentDelay' => $data->getPaymentDelay(),
|
||||
'paymentType' => $data->getPaymentType(),
|
||||
'bank' => $data->getBank(),
|
||||
};
|
||||
|
||||
if ($this->fieldChanged($data, $field, $newValue)) {
|
||||
$changed[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une
|
||||
* entite non geree (creation/POST), l'etat persiste est vide : toute valeur
|
||||
* non-null est alors un changement.
|
||||
*/
|
||||
private function fieldChanged(Provider $data, string $field, mixed $newValue): bool
|
||||
{
|
||||
$original = $this->originalData($data);
|
||||
|
||||
return $newValue !== ($original[$field] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot des valeurs persistees de l'entite (telles que chargees, avant
|
||||
* application du payload). Vide pour une entite non geree (POST).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function originalData(Provider $data): array
|
||||
{
|
||||
if (!$this->em->contains($data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->em->getUnitOfWork()->getOriginalEntityData($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisation serveur du formulaire principal (RG-3.11). Seul companyName est
|
||||
* porte par le Provider (les champs de contact sont normalises par le processor
|
||||
* de sous-ressource ProviderContact, ticket dedie). Le setter non-nullable n'est
|
||||
* touche que si une valeur est presente, pour ne jamais ecraser l'existant lors
|
||||
* d'un PATCH partiel.
|
||||
*/
|
||||
private function normalize(Provider $data): void
|
||||
{
|
||||
if (null !== $data->getCompanyName()) {
|
||||
$data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cles ecrivables effectivement presentes dans le payload : on retire les cles
|
||||
* JSON-LD (@id, @context...) et tout champ non rattache a un groupe d'ecriture
|
||||
* connu. Base du 422 d'archivage (RG-3.13).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function writablePayloadKeys(): array
|
||||
{
|
||||
$writable = array_merge(
|
||||
self::MAIN_FIELDS,
|
||||
self::ACCOUNTING_FIELDS,
|
||||
[self::ARCHIVE_FIELD],
|
||||
);
|
||||
|
||||
return array_values(array_intersect($this->payloadKeys(), $writable));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cles de premier niveau effectivement envoyees par le client (payload JSON
|
||||
* brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
/**
|
||||
* 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();
|
||||
if (null === $request) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = $request->getContent();
|
||||
|
||||
// Cache hit : meme corps brut que le dernier decodage -> memes cles.
|
||||
if ($content === $this->decodedContent) {
|
||||
return $this->decodedPayloadKeys;
|
||||
}
|
||||
|
||||
$this->decodedContent = $content;
|
||||
$this->decodedPayloadKeys = $this->extractPayloadKeys($content);
|
||||
|
||||
return $this->decodedPayloadKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode le corps brut et en extrait les cles de premier niveau (chaines).
|
||||
* Corps vide ou JSON invalide -> aucune cle.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractPayloadKeys(string $content): array
|
||||
{
|
||||
if ('' === $content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Leve une 422 portant une violation unique sur `sites` — meme rendu Hydra que
|
||||
* les contraintes Symfony, consommable inline par extractApiViolations (ERP-101).
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
private function throwSitesViolation(Provider $root): void
|
||||
{
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Vous ne pouvez rattacher que des sites auxquels vous avez accès.',
|
||||
null,
|
||||
[],
|
||||
$root,
|
||||
'sites',
|
||||
null,
|
||||
));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Entity\ProviderRib;
|
||||
use 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
|
||||
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider du repertoire prestataires (M3). Cf. spec-back M3 § 4.1 / § 4.2 +
|
||||
* RG-3.16 / RG-3.17. Jumeau du SupplierProvider (M2), augmente du cloisonnement
|
||||
* par site pilote par l'utilisateur (§ 2.13).
|
||||
*
|
||||
* Collection (GET /api/providers) :
|
||||
* - exclut par defaut les archives (is_archived = true) ET les soft-deletes
|
||||
* (deleted_at IS NOT NULL) — RG-3.16 ;
|
||||
* - ?includeArchived=true reintegre les archives (les soft-deletes restent
|
||||
* exclus au M3) — RG-3.16 ;
|
||||
* - tri par defaut companyName ASC — RG-3.16 ;
|
||||
* - filtres ?search=... (fuzzy companyName + contacts lies : firstName /
|
||||
* lastName / email), ?categoryCode=<code> (prestataires ayant >= 1 categorie
|
||||
* de ce code, repetable) et ?siteId=<id> (prestataires rattaches a ce site
|
||||
* via la relation DIRECTE provider.sites, repetable) ;
|
||||
* - pagination obligatoire (regle ABSOLUE n°13) : Paginator ORM ; echappatoire
|
||||
* ?pagination=false pour alimenter un <select> sans pagination.
|
||||
*
|
||||
* Cloisonnement par site (RG-3.17, § 2.13) — applique ICI (le QueryBuilder du
|
||||
* repository ne connait pas l'user courant) :
|
||||
* - si l'user N'A PAS `sites.bypass_scope` ET que CurrentSiteProvider::get()
|
||||
* retourne un site -> la liste est restreinte aux prestataires dont
|
||||
* provider.sites contient le currentSite (repository::applySiteScope), AVANT
|
||||
* pagination : totalItems reflete le perimetre de l'user ;
|
||||
* - le DETAIL (Get / provider de PATCH) d'un prestataire hors perimetre renvoie
|
||||
* 404 (null) — ne pas reveler l'existence d'une ligne hors site ;
|
||||
* - user `bypass_scope` (Admin auto, profils consolidation) -> aucun filtre ;
|
||||
* - currentSite = null (module Sites off / user sans site) -> no-op lecture
|
||||
* (aligne site-aware.md § 5).
|
||||
*
|
||||
* Item (GET /api/providers/{id} + provider de PATCH) :
|
||||
* - 404 si introuvable OU soft-delete (deleted_at non null, jamais expose au
|
||||
* M3) ; les archives restent consultables/restaurables en detail ;
|
||||
* - 404 si hors perimetre site (cloisonnement, cf. ci-dessus).
|
||||
*
|
||||
* Le filtrage des champs comptables en lecture (groupe provider:read:accounting)
|
||||
* n'est PAS fait ici mais dans ProviderReadGroupContextBuilder : un Provider
|
||||
* retourne des donnees mais ne peut pas influencer les groupes de serialisation.
|
||||
*
|
||||
* @implements ProviderInterface<Provider>
|
||||
*/
|
||||
final class ProviderProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
|
||||
private readonly ProviderRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
// 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
|
||||
{
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
return $this->provideCollection($operation, $context);
|
||||
}
|
||||
|
||||
return $this->provideItem($uriVariables);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*
|
||||
* @return list<Provider>|Paginator<Provider>
|
||||
*/
|
||||
private function provideCollection(Operation $operation, array $context): array|Paginator
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
||||
$archivedOnly = $this->readBool($filters['archivedOnly'] ?? false);
|
||||
$search = $filters['search'] ?? null;
|
||||
// categoryCode accepte un code unique (?categoryCode=NETTOYAGE, selects)
|
||||
// OU une liste (?categoryCode[]=A&categoryCode[]=B, drawer multi).
|
||||
$categoryCodes = $this->readStringList($filters['categoryCode'] ?? []);
|
||||
$siteIds = $this->readIntList($filters['siteId'] ?? []);
|
||||
|
||||
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
|
||||
$qb = $this->repository->createListQueryBuilder(
|
||||
$includeArchived,
|
||||
is_string($search) ? $search : null,
|
||||
$categoryCodes,
|
||||
$siteIds,
|
||||
$archivedOnly,
|
||||
);
|
||||
|
||||
// Cloisonnement par site (RG-3.17) AVANT pagination : ajoute une clause
|
||||
// restreignant au currentSite pour un user non-bypass. S'intersecte avec
|
||||
// un eventuel filtre ?siteId du client (deux sous-requetes ANDees).
|
||||
$scopeSite = $this->scopeChecker->siteScopeOrNull();
|
||||
if (null !== $scopeSite) {
|
||||
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
|
||||
}
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||
// (regle n°13 — utile pour un <select> cote front).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
/** @var list<Provider> $providers */
|
||||
$providers = $qb->getQuery()->getResult();
|
||||
// Hydratation batchee des collections affichees (§ 2.12) : evite le
|
||||
// N+1 si la serialisation touche categories/sites, sans cartesien.
|
||||
$this->repository->hydrateListCollections($providers);
|
||||
|
||||
return $providers;
|
||||
}
|
||||
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
// Le QB de selection ne porte pas de fetch-join to-many (§ 2.12) : le
|
||||
// COUNT est simple, fetchJoinCollection inutile. On materialise la page
|
||||
// puis on hydrate ses collections en lot (memes entites managees).
|
||||
$paginator = new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
|
||||
$this->repository->hydrateListCollections(iterator_to_array($paginator));
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $uriVariables
|
||||
*/
|
||||
private function provideItem(array $uriVariables): ?Provider
|
||||
{
|
||||
$id = $uriVariables['id'] ?? null;
|
||||
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$provider = $this->repository->findById((int) $id);
|
||||
if (null === $provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Soft-delete : jamais expose au M3 (HP-M4) — 404 via retour null.
|
||||
// Les archives restent visibles en detail (consultation + restauration).
|
||||
if (null !== $provider->getDeletedAt()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cloisonnement par site (RG-3.17) : un prestataire hors du perimetre de
|
||||
// l'user -> 404 (ne pas reveler son existence). No-op pour bypass_scope ou
|
||||
// currentSite null (delegue au ProviderSiteScopeChecker).
|
||||
if (!$this->scopeChecker->isInScope($provider)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||
*/
|
||||
private function readBool(mixed $raw): bool
|
||||
{
|
||||
if (is_bool($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste de chaines. Tolere un code unique (string)
|
||||
* ou une liste (?key[]=a&key[]=b). Trim + retrait des vides.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function readStringList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_string($value) && '' !== trim($value)) {
|
||||
$out[] = trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste d'identifiants entiers positifs. Tolere une
|
||||
* valeur unique ou une liste (?key[]=1&key[]=2).
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readIntList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
|
||||
$out[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Entity\ProviderContact;
|
||||
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderProvider;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Export XLSX du repertoire prestataires (M3, spec-back § 4.6). Jumeau du
|
||||
* `SupplierExportController` (M2, module Commercial), augmente du cloisonnement
|
||||
* par site pilote par l'utilisateur (§ 2.13).
|
||||
*
|
||||
* Controller Symfony custom (et non operation API Platform) car il produit un
|
||||
* binaire de fichier, pas une representation Hydra. `priority: 1` est
|
||||
* OBLIGATOIRE sur la route : sans cela API Platform capterait
|
||||
* `/api/providers/export.xlsx` comme l'item `GET /api/providers/{id}.{_format}`
|
||||
* (id="export", _format="xlsx") — cf. CLAUDE.md « controller custom sous /api ».
|
||||
*
|
||||
* Separation des responsabilites :
|
||||
* - le COMMENT (generation du fichier) est delegue au service Shared
|
||||
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
|
||||
* - le QUOI vit ICI : selection des prestataires (memes filtres que
|
||||
* `GET /api/providers`, via {@see ProviderRepositoryInterface::createListQueryBuilder()}),
|
||||
* cloisonnement par site, et mapping metier des colonnes.
|
||||
*
|
||||
* Cloisonnement par site (RG-3.17, § 2.13) : replique la logique du
|
||||
* {@see ProviderProvider}
|
||||
* — un user sans `sites.bypass_scope` et possedant un currentSite n'exporte que
|
||||
* les prestataires rattaches a ce site (relation DIRECTE provider.sites). Le
|
||||
* QueryBuilder ne connait pas l'user : la decision est prise ICI, le DQL dans le
|
||||
* repository (applySiteScope).
|
||||
*
|
||||
* Colonnes de contact : alimentees par le CONTACT PRINCIPAL du prestataire — le
|
||||
* ProviderContact de plus petit `position` (decision D2, spec § 4.6).
|
||||
*
|
||||
* La colonne SIREN n'est ajoutee que si l'utilisateur a la permission
|
||||
* `technique.providers.accounting.view` (gating identique a la lecture, § 2.9).
|
||||
*/
|
||||
#[AsController]
|
||||
final class ProviderExportController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
|
||||
private readonly ProviderRepositoryInterface $repository,
|
||||
private readonly SpreadsheetExporterInterface $exporter,
|
||||
private readonly Security $security,
|
||||
// Outillage site-aware (cf. ProviderProvider) : resout le site courant pour
|
||||
// appliquer le cloisonnement RG-3.17 a l'export comme a la liste.
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
) {}
|
||||
|
||||
#[Route('/api/providers/export.xlsx', name: 'technique_providers_export_xlsx', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('technique.providers.view')]
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
// Memes filtres d'archivage que la vue liste (ProviderProvider) pour que
|
||||
// l'export reflete exactement ce que l'utilisateur voit a l'ecran :
|
||||
// - includeArchived : inclut les archives en plus des actifs ;
|
||||
// - archivedOnly : restreint aux seules archives (prioritaire, cf.
|
||||
// createListQueryBuilder).
|
||||
$includeArchived = $this->readBool($request->query->get('includeArchived'));
|
||||
$archivedOnly = $this->readBool($request->query->get('archivedOnly'));
|
||||
$search = $request->query->getString('search') ?: null;
|
||||
|
||||
// Memes filtres que la vue liste : categoryCode/siteId tolerent une valeur
|
||||
// unique ou une liste (?categoryCode[]=A&siteId[]=1). On lit via all() pour
|
||||
// ne pas lever d'exception sur une valeur scalaire.
|
||||
$query = $request->query->all();
|
||||
$categoryCodes = $this->readStringList($query['categoryCode'] ?? []);
|
||||
$siteIds = $this->readIntList($query['siteId'] ?? []);
|
||||
|
||||
$qb = $this->repository
|
||||
->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly)
|
||||
;
|
||||
|
||||
// Cloisonnement par site (RG-3.17, § 2.13) AVANT materialisation : restreint
|
||||
// au currentSite pour un user non-bypass (s'intersecte avec un eventuel
|
||||
// ?siteId du client). No-op pour bypass_scope ou currentSite null.
|
||||
$scopeSite = $this->siteScopeOrNull();
|
||||
if (null !== $scopeSite) {
|
||||
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
|
||||
}
|
||||
|
||||
/** @var list<Provider> $providers */
|
||||
$providers = $qb->getQuery()->getResult();
|
||||
|
||||
// Hydratation batchee des collections affichees (§ 2.12) : le QB de
|
||||
// selection ne fetch-join pas les to-many. On remplit categories + sites en
|
||||
// lot (colonnes « Catégories » / « Sites »), puis les contacts (colonnes du
|
||||
// contact principal) — chacune en requetes IN bornees, anti N+1.
|
||||
$this->repository->hydrateListCollections($providers);
|
||||
$this->repository->hydrateContacts($providers);
|
||||
|
||||
$withSiren = $this->security->isGranted('technique.providers.accounting.view');
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
'Répertoire prestataires',
|
||||
$this->buildHeaders($withSiren),
|
||||
$this->buildRows($providers, $withSiren),
|
||||
);
|
||||
|
||||
return $this->buildResponse($binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Site de cloisonnement a appliquer en LECTURE, ou null si aucun cloisonnement
|
||||
* (user `sites.bypass_scope`, ou pas de site courant resolu — module Sites off
|
||||
* / user sans currentSite). Miroir de ProviderProvider::siteScopeOrNull().
|
||||
*/
|
||||
private function siteScopeOrNull(): ?SiteInterface
|
||||
{
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->currentSiteProvider->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes de l'export (spec § 4.6). SIREN inseree avant la date de creation,
|
||||
* uniquement si l'utilisateur a accounting.view.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildHeaders(bool $withSiren): array
|
||||
{
|
||||
$headers = [
|
||||
'Nom prestataire',
|
||||
'Contact principal',
|
||||
'Téléphone principal',
|
||||
'Téléphone secondaire',
|
||||
'Email',
|
||||
'Catégories',
|
||||
'Sites',
|
||||
];
|
||||
|
||||
if ($withSiren) {
|
||||
$headers[] = 'SIREN';
|
||||
}
|
||||
|
||||
$headers[] = 'Date de création';
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Provider> $providers
|
||||
*
|
||||
* @return iterable<list<null|scalar>>
|
||||
*/
|
||||
private function buildRows(array $providers, bool $withSiren): iterable
|
||||
{
|
||||
foreach ($providers as $provider) {
|
||||
$contact = $this->principalContact($provider);
|
||||
|
||||
$row = [
|
||||
$provider->getCompanyName(),
|
||||
null !== $contact ? $this->formatContactName($contact) : '',
|
||||
$contact?->getPhonePrimary() ?? '',
|
||||
$contact?->getPhoneSecondary() ?? '',
|
||||
$contact?->getEmail() ?? '',
|
||||
$this->formatCategories($provider),
|
||||
$this->formatSites($provider),
|
||||
];
|
||||
|
||||
if ($withSiren) {
|
||||
$row[] = $provider->getSiren();
|
||||
}
|
||||
|
||||
$row[] = $provider->getCreatedAt()?->format('d/m/Y');
|
||||
|
||||
yield $row;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact principal du prestataire : le ProviderContact de plus petit
|
||||
* `position` (decision D2, spec § 4.6). Null si le prestataire n'a aucun
|
||||
* contact (les colonnes contact restent vides).
|
||||
*/
|
||||
private function principalContact(Provider $provider): ?ProviderContact
|
||||
{
|
||||
$contacts = $provider->getContacts()->toArray();
|
||||
if ([] === $contacts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort(
|
||||
$contacts,
|
||||
static fn (ProviderContact $a, ProviderContact $b): int => $a->getPosition() <=> $b->getPosition(),
|
||||
);
|
||||
|
||||
return $contacts[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelle du contact principal « Nom Prénom » (spec § 4.6). Les deux parties
|
||||
* sont optionnelles (RG-3.04 : au moins l'une des deux), d'ou le trim final.
|
||||
*/
|
||||
private function formatContactName(ProviderContact $contact): string
|
||||
{
|
||||
return trim(sprintf('%s %s', $contact->getLastName() ?? '', $contact->getFirstName() ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelles des categories du prestataire, dedupliques, tries, joints par
|
||||
* virgule.
|
||||
*/
|
||||
private function formatCategories(Provider $provider): string
|
||||
{
|
||||
$names = [];
|
||||
foreach ($provider->getCategories() as $category) {
|
||||
// @var CategoryInterface $category
|
||||
$name = $category->getName();
|
||||
if (null !== $name && '' !== $name) {
|
||||
$names[$name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->joinSorted($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sites du prestataire (relation DIRECTE provider.sites, RG-3.03 — contrairement
|
||||
* au fournisseur M2 dont les sites sont portes par les adresses). La colonne
|
||||
* « Sites » agrege l'union distincte des sites rattaches.
|
||||
*/
|
||||
private function formatSites(Provider $provider): string
|
||||
{
|
||||
$names = [];
|
||||
foreach ($provider->getSites() as $site) {
|
||||
// @var SiteInterface $site
|
||||
$name = $site->getName();
|
||||
if (null !== $name && '' !== $name) {
|
||||
$names[$name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->joinSorted($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $names ensemble de libelles (cles)
|
||||
*/
|
||||
private function joinSorted(array $names): string
|
||||
{
|
||||
$list = array_keys($names);
|
||||
sort($list);
|
||||
|
||||
return implode(', ', $list);
|
||||
}
|
||||
|
||||
private function buildResponse(string $binary): Response
|
||||
{
|
||||
$filename = sprintf('repertoire-prestataires-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
|
||||
|
||||
$response = new Response($binary);
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||
* Aligne sur ProviderProvider pour un comportement identique a la liste.
|
||||
*/
|
||||
private function readBool(mixed $raw): bool
|
||||
{
|
||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste de chaines (valeur unique ou liste).
|
||||
* Aligne sur ProviderProvider pour un comportement identique a la liste.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function readStringList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_string($value) && '' !== trim($value)) {
|
||||
$out[] = trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
|
||||
* ou liste). Aligne sur ProviderProvider.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readIntList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
|
||||
$out[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -89,26 +89,6 @@ class DoctrineProviderRepository extends ServiceEntityRepository implements Prov
|
||||
;
|
||||
}
|
||||
|
||||
public function applySiteScope(QueryBuilder $qb, int $siteId): void
|
||||
{
|
||||
// Cloisonnement par site (RG-3.17, § 2.13) : ne garder que les prestataires
|
||||
// dont provider.sites contient le site donne. Sous-requete IN (alias p5
|
||||
// distinct des filtres p2/p3/p4) pour ne pas perturber le tri/pagination du
|
||||
// QueryBuilder principal — meme parti pris que applyCategoryCodes / applySiteIds.
|
||||
// Parametre :scopeSiteId distinct de :siteIds (filtre ?siteId du client) pour
|
||||
// que les deux clauses puissent coexister (intersection) sans collision.
|
||||
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('p5.id')
|
||||
->from(Provider::class, 'p5')
|
||||
->join('p5.sites', 'site5')
|
||||
->where('site5.id = :scopeSiteId')
|
||||
;
|
||||
|
||||
$qb->andWhere($qb->expr()->in('p.id', $sub->getDQL()))
|
||||
->setParameter('scopeSiteId', $siteId)
|
||||
;
|
||||
}
|
||||
|
||||
public function hydrateContacts(array $providers): void
|
||||
{
|
||||
$ids = $this->collectIds($providers);
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Application\Idtf;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use function array_slice;
|
||||
|
||||
/**
|
||||
* Parsing pur d'une matrice (lignes/colonnes 0-indexees, telle que retournee
|
||||
* par PhpSpreadsheet::toArray) de l'export Excel IDTF vers des lignes
|
||||
* normalisees pretes a l'upsert. Sans dependance a PhpSpreadsheet : la matrice
|
||||
* est un simple tableau, ce qui rend le parsing testable en isolation.
|
||||
*
|
||||
* Robuste au reordonnancement des colonnes (mapping par libelle normalise) et
|
||||
* aux lignes de preambule (detection dynamique de la ligne d'en-tete). Voir
|
||||
* ERP-149 § 2.
|
||||
*/
|
||||
final class IdtfSheetParser
|
||||
{
|
||||
/**
|
||||
* @param array<int, array<int, mixed>> $matrix
|
||||
*
|
||||
* @return array{exportDate: null|string, rows: list<array<string, mixed>>}
|
||||
*/
|
||||
public static function parse(array $matrix): array
|
||||
{
|
||||
$exportDate = self::extractExportDate($matrix);
|
||||
$headerIndex = self::findHeaderIndex($matrix);
|
||||
|
||||
if (null === $headerIndex) {
|
||||
throw new RuntimeException("Ligne d'en-tete introuvable (colonne 'Numero IDTF').");
|
||||
}
|
||||
|
||||
$map = self::buildColumnMap($matrix[$headerIndex]);
|
||||
|
||||
if (!isset($map['idtf_number'])) {
|
||||
throw new RuntimeException("Colonne 'Numero IDTF' introuvable dans l'en-tete.");
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach (array_slice($matrix, $headerIndex + 1) as $row) {
|
||||
$idtf = trim((string) ($row[$map['idtf_number']] ?? ''));
|
||||
|
||||
// Ligne vide / non exploitable : pas d'identifiant numerique.
|
||||
if ('' === $idtf || !ctype_digit($idtf)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'idtf_number' => (int) $idtf,
|
||||
'product_group' => self::val($row, $map['product_group'] ?? null),
|
||||
'name' => self::val($row, $map['name'] ?? null) ?? '',
|
||||
'cleaning_regime' => self::val($row, $map['cleaning_regime'] ?? null) ?? '',
|
||||
'important_requirements' => self::val($row, $map['important_requirements'] ?? null),
|
||||
'mandatory_date' => self::parseDate(self::val($row, $map['mandatory_date'] ?? null)),
|
||||
'related_products' => self::val($row, $map['related_products'] ?? null),
|
||||
'formula' => self::val($row, $map['formula'] ?? null),
|
||||
'eural_code' => self::val($row, $map['eural_code'] ?? null),
|
||||
'cas_numbers' => self::splitCas(self::val($row, $map['cas'] ?? null)),
|
||||
'footnotes' => self::val($row, $map['footnotes'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
return ['exportDate' => $exportDate, 'rows' => $rows];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cherche une date "d-m-Y" dans les premieres lignes (preambule
|
||||
* "Export date: 12-6-2026") et la convertit en "Y-m-d". Null si absente.
|
||||
*
|
||||
* @param array<int, array<int, mixed>> $matrix
|
||||
*/
|
||||
public static function extractExportDate(array $matrix): ?string
|
||||
{
|
||||
foreach (array_slice($matrix, 0, 5) as $row) {
|
||||
$line = implode(' ', array_map(static fn (mixed $c): string => (string) $c, $row));
|
||||
|
||||
if (preg_match('/(\d{1,2})-(\d{1,2})-(\d{4})/', $line, $m)) {
|
||||
$day = (int) $m[1];
|
||||
$month = (int) $m[2];
|
||||
$year = (int) $m[3];
|
||||
|
||||
if (checkdate($month, $day, $year)) {
|
||||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index de la ligne d'en-tete : premiere ligne contenant une cellule dont
|
||||
* le libelle normalise contient "numero idtf".
|
||||
*
|
||||
* @param array<int, array<int, mixed>> $matrix
|
||||
*/
|
||||
private static function findHeaderIndex(array $matrix): ?int
|
||||
{
|
||||
foreach ($matrix as $i => $row) {
|
||||
foreach ($row as $cell) {
|
||||
if (str_contains(self::normalize((string) $cell), 'numero idtf')) {
|
||||
return $i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le mapping logique -> index de colonne a partir de la ligne
|
||||
* d'en-tete (resiste au reordonnancement via fields[]).
|
||||
*
|
||||
* @param array<int, mixed> $header
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private static function buildColumnMap(array $header): array
|
||||
{
|
||||
$map = [];
|
||||
|
||||
foreach ($header as $col => $label) {
|
||||
$n = self::normalize((string) $label);
|
||||
|
||||
$key = match (true) {
|
||||
str_contains($n, 'numero idtf') => 'idtf_number',
|
||||
str_contains($n, 'product group'),
|
||||
str_contains($n, 'groupe') => 'product_group',
|
||||
str_contains($n, 'nom de la marchandise') => 'name',
|
||||
str_contains($n, 'regime de nettoyage') => 'cleaning_regime',
|
||||
str_contains($n, 'exigences importantes') => 'important_requirements',
|
||||
str_contains($n, 'date d application') => 'mandatory_date',
|
||||
str_contains($n, 'produits apparentes') => 'related_products',
|
||||
str_contains($n, 'formule') => 'formula',
|
||||
str_contains($n, 'code eural') => 'eural_code',
|
||||
str_contains($n, 'numero cas') => 'cas',
|
||||
str_contains($n, 'annotations') => 'footnotes',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (null !== $key && !isset($map[$key])) {
|
||||
$map[$key] = (int) $col;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une date "dd-mm-yyyy" en "yyyy-mm-dd". Null si format invalide
|
||||
* ou date calendaire impossible.
|
||||
*/
|
||||
private static function parseDate(?string $raw): ?string
|
||||
{
|
||||
if (null === $raw || !preg_match('/^(\d{1,2})-(\d{1,2})-(\d{4})$/', $raw, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$day = (int) $m[1];
|
||||
$month = (int) $m[2];
|
||||
$year = (int) $m[3];
|
||||
|
||||
if (!checkdate($month, $day, $year)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eclate une cellule "Numero CAS" sur ';' en liste de chaines non vides.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function splitCas(?string $raw): array
|
||||
{
|
||||
if (null === $raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$parts = array_map('trim', explode(';', $raw));
|
||||
|
||||
return array_values(array_filter($parts, static fn (string $v): bool => '' !== $v));
|
||||
}
|
||||
|
||||
/**
|
||||
* Valeur d'une cellule par index : trim, null si absente/vide.
|
||||
*
|
||||
* @param array<int, mixed> $row
|
||||
*/
|
||||
private static function val(array $row, ?int $col): ?string
|
||||
{
|
||||
if (null === $col) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$v = trim((string) ($row[$col] ?? ''));
|
||||
|
||||
return '' === $v ? null : $v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un libelle d'en-tete : minuscules, sans accents ni apostrophes,
|
||||
* espaces compresses (pour un matching robuste).
|
||||
*/
|
||||
private static function normalize(string $s): string
|
||||
{
|
||||
$s = str_replace(['’', "'"], ' ', $s);
|
||||
$s = (string) iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
|
||||
$s = mb_strtolower($s);
|
||||
|
||||
return trim((string) preg_replace('/\s+/', ' ', $s));
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\Console;
|
||||
|
||||
use App\Module\Transport\Application\Idtf\IdtfSheetParser;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Throwable;
|
||||
|
||||
use function array_slice;
|
||||
use function count;
|
||||
use function in_array;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
use const JSON_UNESCAPED_UNICODE;
|
||||
|
||||
/**
|
||||
* ERP-149 : synchronise le referentiel des codes IDTF (regimes de nettoyage
|
||||
* transport).
|
||||
*
|
||||
* Recupere l'export Excel depuis le generateur icrt-idtf.com (ou un fichier
|
||||
* local), le parse et synchronise `idtf_product` de facon transactionnelle :
|
||||
* upsert sur (schema, idtf_number), soft-delete des absents, journal dans
|
||||
* `idtf_sync_log`. Idempotente (refresh complet).
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:idtf:sync',
|
||||
description: 'Synchronise le referentiel des codes IDTF depuis l\'export Excel icrt-idtf.com (upsert + soft-delete + journal).',
|
||||
)]
|
||||
final class SyncIdtfCommand extends Command
|
||||
{
|
||||
private const string GENERATOR_URL = 'https://www.icrt-idtf.com/fr/excel-generator/';
|
||||
|
||||
/**
|
||||
* Champs a cocher explicitement : `fields[]=all` ne deplie PAS les colonnes
|
||||
* cote serveur (6 colonnes seulement). Cette liste donne l'export complet
|
||||
* (11 colonnes). Cf. ERP-149 § 1.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const array EXPORT_FIELDS = [
|
||||
'product_number_idtf',
|
||||
'product_name',
|
||||
'minimum_cleaning_regime',
|
||||
'important_requirements',
|
||||
'date_mandatory',
|
||||
'related_products',
|
||||
'formula',
|
||||
'product_number_eural',
|
||||
'product_number_cas',
|
||||
'footnotes',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('schema', null, InputOption::VALUE_REQUIRED, "Module IDTF : 'road' (routier) ou 'water' (fluvial).", 'road')
|
||||
->addOption('file', null, InputOption::VALUE_REQUIRED, "Chemin d'un .xlsx local (court-circuite le telechargement, utile pour tests/rejeu).")
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Analyse sans ecriture en base.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$schema = (string) $input->getOption('schema');
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
$file = $input->getOption('file');
|
||||
|
||||
if (!in_array($schema, ['road', 'water'], true)) {
|
||||
$io->error("--schema doit valoir 'road' ou 'water'.");
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
// 1. Recuperation du binaire xlsx (local ou via POST).
|
||||
try {
|
||||
$xlsx = null !== $file ? $this->readLocal((string) $file) : $this->downloadExport($schema);
|
||||
} catch (Throwable $e) {
|
||||
$io->error('Telechargement/lecture impossible : '.$e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// 2. Parsing (xlsx -> matrice -> lignes normalisees).
|
||||
try {
|
||||
$parsed = IdtfSheetParser::parse($this->toMatrix($xlsx));
|
||||
} catch (Throwable $e) {
|
||||
$io->error('Parsing impossible : '.$e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$rows = $parsed['rows'];
|
||||
$exportDate = $parsed['exportDate'] ?? new DateTimeImmutable()->format('Y-m-d');
|
||||
|
||||
$io->section(sprintf('IDTF %s — export du %s', mb_strtoupper($schema), $exportDate));
|
||||
$io->writeln(sprintf('%d lignes exploitables lues.', count($rows)));
|
||||
|
||||
if ($dryRun) {
|
||||
$this->renderPreview($io, $rows);
|
||||
$io->note(sprintf('Dry-run : aucune ecriture. (%d lignes au total)', count($rows)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// 3. Sync transactionnelle : upsert -> soft-delete -> journal.
|
||||
$run = new DateTimeImmutable()->format('Y-m-d H:i:s.u');
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
try {
|
||||
$upserted = $this->upsertAll($schema, $exportDate, $rows, $run);
|
||||
$deactivated = $this->deactivateMissing($schema, $run);
|
||||
$this->log($schema, $exportDate, count($rows), $upserted, $deactivated);
|
||||
$this->connection->commit();
|
||||
} catch (Throwable $e) {
|
||||
$this->connection->rollBack();
|
||||
$io->error('Sync annulee (rollback) : '.$e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d upsert, %d desactive(s).', $upserted, $deactivated));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejoue le POST du generateur pour recuperer le binaire xlsx complet.
|
||||
* Le formulaire poste sur lui-meme ; pas besoin de GET/cookies prealables.
|
||||
*/
|
||||
private function downloadExport(string $schema): string
|
||||
{
|
||||
// Corps construit a la main : http-client encoderait fields[] en
|
||||
// indices numerotes, on veut bien des "fields[]=..." repetes.
|
||||
$pairs = [
|
||||
'schema='.$schema,
|
||||
'type%5B%5D='.$schema,
|
||||
'roadRegime%5B%5D=all',
|
||||
'waterRegime%5B%5D=all',
|
||||
'groups%5B%5D=all',
|
||||
'products%5B%5D=all',
|
||||
];
|
||||
|
||||
foreach (self::EXPORT_FIELDS as $field) {
|
||||
$pairs[] = 'fields%5B%5D='.$field;
|
||||
}
|
||||
|
||||
$pairs[] = 'generateExcel=';
|
||||
|
||||
$response = $this->httpClient->request('POST', self::GENERATOR_URL, [
|
||||
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
|
||||
'body' => implode('&', $pairs),
|
||||
'timeout' => 90,
|
||||
]);
|
||||
|
||||
$content = $response->getContent();
|
||||
$contentType = $response->getHeaders(false)['content-type'][0] ?? '';
|
||||
|
||||
// Garde-fou : un HTML signifie un POST rejete (filtres/payload).
|
||||
if (!str_contains($contentType, 'spreadsheet') && !str_starts_with($content, "PK\x03\x04")) {
|
||||
throw new RuntimeException(sprintf('Reponse non-xlsx (content-type: %s). Verifie le payload.', $contentType));
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function readLocal(string $path): string
|
||||
{
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if (false === $raw) {
|
||||
throw new RuntimeException(sprintf('Fichier illisible : %s', $path));
|
||||
}
|
||||
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge le binaire xlsx via PhpSpreadsheet et retourne la feuille active
|
||||
* sous forme de matrice 0-indexee (lignes/colonnes).
|
||||
*
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
private function toMatrix(string $xlsx): array
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'idtf_').'.xlsx';
|
||||
file_put_contents($tmp, $xlsx);
|
||||
|
||||
try {
|
||||
// toArray(null, true, true, false) : colonnes 0-indexees.
|
||||
return IOFactory::load($tmp)->getActiveSheet()->toArray(null, true, true, false);
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert de toutes les lignes (cle naturelle = schema + idtf_number).
|
||||
*
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function upsertAll(string $schema, string $exportDate, array $rows, string $run): int
|
||||
{
|
||||
$sql = <<<'SQL'
|
||||
INSERT INTO idtf_product
|
||||
(idtf_number, schema, product_group, name, cleaning_regime, important_requirements,
|
||||
mandatory_date, related_products, formula, eural_code, cas_numbers, footnotes,
|
||||
source_export_date, is_active, last_synced_at)
|
||||
VALUES
|
||||
(:idtf, :schema, :grp, :name, :regime, :req, :mdate, :related, :formula, :eural,
|
||||
CAST(:cas AS JSONB), :foot, :export, TRUE, :run)
|
||||
ON CONFLICT (schema, idtf_number) DO UPDATE SET
|
||||
product_group = EXCLUDED.product_group,
|
||||
name = EXCLUDED.name,
|
||||
cleaning_regime = EXCLUDED.cleaning_regime,
|
||||
important_requirements = EXCLUDED.important_requirements,
|
||||
mandatory_date = EXCLUDED.mandatory_date,
|
||||
related_products = EXCLUDED.related_products,
|
||||
formula = EXCLUDED.formula,
|
||||
eural_code = EXCLUDED.eural_code,
|
||||
cas_numbers = EXCLUDED.cas_numbers,
|
||||
footnotes = EXCLUDED.footnotes,
|
||||
source_export_date = EXCLUDED.source_export_date,
|
||||
is_active = TRUE,
|
||||
last_synced_at = EXCLUDED.last_synced_at
|
||||
SQL;
|
||||
|
||||
$count = 0;
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$this->connection->executeStatement($sql, [
|
||||
'idtf' => $r['idtf_number'],
|
||||
'schema' => $schema,
|
||||
'grp' => $r['product_group'],
|
||||
'name' => $r['name'],
|
||||
'regime' => $r['cleaning_regime'],
|
||||
'req' => $r['important_requirements'],
|
||||
'mdate' => $r['mandatory_date'],
|
||||
'related' => $r['related_products'],
|
||||
'formula' => $r['formula'],
|
||||
'eural' => $r['eural_code'],
|
||||
'cas' => json_encode($r['cas_numbers'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'foot' => $r['footnotes'],
|
||||
'export' => $exportDate,
|
||||
'run' => $run,
|
||||
]);
|
||||
++$count;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete : toute ligne du schema active non revue par ce run passe a
|
||||
* is_active=false.
|
||||
*/
|
||||
private function deactivateMissing(string $schema, string $run): int
|
||||
{
|
||||
return (int) $this->connection->executeStatement(
|
||||
'UPDATE idtf_product SET is_active = FALSE WHERE schema = :schema AND is_active = TRUE AND last_synced_at < :run',
|
||||
['schema' => $schema, 'run' => $run],
|
||||
);
|
||||
}
|
||||
|
||||
private function log(string $schema, string $exportDate, int $total, int $upserted, int $deactivated): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
<<<'SQL'
|
||||
INSERT INTO idtf_sync_log (schema, export_date, rows_total, rows_upserted, rows_deactivated)
|
||||
VALUES (:schema, :export, :total, :upserted, :deactivated)
|
||||
SQL,
|
||||
[
|
||||
'schema' => $schema,
|
||||
'export' => $exportDate,
|
||||
'total' => $total,
|
||||
'upserted' => $upserted,
|
||||
'deactivated' => $deactivated,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function renderPreview(SymfonyStyle $io, array $rows): void
|
||||
{
|
||||
$io->table(
|
||||
['IDTF', 'Nom', 'Regime', 'CAS'],
|
||||
array_map(static fn (array $r): array => [
|
||||
(string) $r['idtf_number'],
|
||||
mb_strimwidth((string) $r['name'], 0, 50, '…'),
|
||||
(string) $r['cleaning_regime'],
|
||||
implode(', ', $r['cas_numbers']),
|
||||
], array_slice($rows, 0, 15)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
<?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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
<?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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?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 [];
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Shared\Infrastructure\ApiPlatform\State\UploadedDocumentProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Reference technique d'un fichier televerse (infra generique Shared, ERP-154).
|
||||
*
|
||||
* Entite IMMUABLE : un document n'est jamais modifie apres creation (pas d'onglet
|
||||
* edition cote front). Elle ne porte donc QUE `created_at` / `created_by` — pas
|
||||
* la paire `updated_*` — et n'implemente volontairement pas Timestampable /
|
||||
* Blamable (qui imposeraient les 4 colonnes). `created_at` est rempli par le
|
||||
* FileUploader via l'horloge injectee ; `created_by` est positionne par le
|
||||
* processor depuis l'utilisateur authentifie (null hors HTTP).
|
||||
*
|
||||
* Pas de `#[Auditable]` : c'est un enregistrement d'infrastructure (et non un
|
||||
* agregat metier edite), sa tracabilite est portee par created_at / created_by.
|
||||
*
|
||||
* Operations API :
|
||||
* - Post (/uploaded_documents, multipart) : `deserialize: false` — le binaire
|
||||
* n'est pas deserialise dans l'entite, le UploadedDocumentProcessor lit le
|
||||
* fichier de la requete, delegue au FileUploader (validation MIME server-side,
|
||||
* bornage taille, checksum, ecriture disque) puis persiste. MIME hors
|
||||
* whitelist -> 422.
|
||||
* - Get (/uploaded_documents/{id}) : necessaire pour qu'API Platform genere
|
||||
* l'IRI renvoyee par le Post. Protege par IS_AUTHENTICATED_FULLY uniquement
|
||||
* (pas de RBAC ni de cloisonnement tenant ici) : cette ressource est une
|
||||
* infra GENERIQUE qui ne porte aucune notion de proprietaire metier. Le
|
||||
* cloisonnement d'acces (qui peut voir quel document) est volontairement
|
||||
* delegue au module CONSOMMATEUR (ex: la Decharge M4), qui exposera le
|
||||
* document via sa propre ressource cloisonnee plutot que via cet endpoint
|
||||
* technique. Ne renvoie que des metadonnees (jamais le binaire).
|
||||
*
|
||||
* Pas de GetCollection exposee (non requise) — la regle de pagination ne
|
||||
* s'applique donc pas ici.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'uploaded_document')]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('IS_AUTHENTICATED_FULLY')",
|
||||
),
|
||||
new Post(
|
||||
// Entree multipart : le binaire arrive en multipart/form-data sous
|
||||
// le champ « file ». Sans cet inputFormats, API Platform rejette la
|
||||
// requete en 415.
|
||||
inputFormats: ['multipart' => ['multipart/form-data']],
|
||||
// Le fichier n'est pas deserialisable dans l'entite : le processor
|
||||
// lit le binaire de la requete. La validation est portee par le
|
||||
// FileUploader (MIME server-side, taille), pas par les contraintes.
|
||||
deserialize: false,
|
||||
validate: false,
|
||||
security: "is_granted('IS_AUTHENTICATED_FULLY')",
|
||||
processor: UploadedDocumentProcessor::class,
|
||||
),
|
||||
],
|
||||
normalizationContext: ['groups' => ['uploaded_document:read']],
|
||||
)]
|
||||
class UploadedDocument
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(name: 'original_filename', length: 255)]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private string $originalFilename;
|
||||
|
||||
#[ORM\Column(name: 'stored_path', length: 512)]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private string $storedPath;
|
||||
|
||||
#[ORM\Column(name: 'mime_type', length: 100)]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private string $mimeType;
|
||||
|
||||
#[ORM\Column(name: 'size_bytes', type: 'integer')]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private int $sizeBytes;
|
||||
|
||||
#[ORM\Column(name: 'checksum', length: 64)]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private string $checksum;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'datetime_immutable')]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?UserInterface $createdBy = null;
|
||||
|
||||
public function __construct(
|
||||
string $originalFilename,
|
||||
string $storedPath,
|
||||
string $mimeType,
|
||||
int $sizeBytes,
|
||||
string $checksum,
|
||||
DateTimeImmutable $createdAt,
|
||||
) {
|
||||
$this->originalFilename = $originalFilename;
|
||||
$this->storedPath = $storedPath;
|
||||
$this->mimeType = $mimeType;
|
||||
$this->sizeBytes = $sizeBytes;
|
||||
$this->checksum = $checksum;
|
||||
$this->createdAt = $createdAt;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getOriginalFilename(): string
|
||||
{
|
||||
return $this->originalFilename;
|
||||
}
|
||||
|
||||
public function getStoredPath(): string
|
||||
{
|
||||
return $this->storedPath;
|
||||
}
|
||||
|
||||
public function getMimeType(): string
|
||||
{
|
||||
return $this->mimeType;
|
||||
}
|
||||
|
||||
public function getSizeBytes(): int
|
||||
{
|
||||
return $this->sizeBytes;
|
||||
}
|
||||
|
||||
public function getChecksum(): string
|
||||
{
|
||||
return $this->checksum;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getCreatedBy(): ?UserInterface
|
||||
{
|
||||
return $this->createdBy;
|
||||
}
|
||||
|
||||
public function setCreatedBy(?UserInterface $user): void
|
||||
{
|
||||
$this->createdBy = $user;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Exception;
|
||||
|
||||
/**
|
||||
* Levee quand le fichier televerse depasse la taille maximale autorisee
|
||||
* (FileUploader::MAX_SIZE_BYTES). Traduite en HTTP 422 par le processor.
|
||||
*/
|
||||
final class FileTooLargeException extends FileUploadException
|
||||
{
|
||||
public function __construct(int $size, int $maxSize)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'Le fichier (%d octets) dépasse la taille maximale autorisée (%d octets).',
|
||||
$size,
|
||||
$maxSize,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Exception de base des erreurs de televersement (FileUploader).
|
||||
*
|
||||
* Decouplee de HTTP : le service Shared\Infrastructure\Upload\FileUploader leve
|
||||
* une de ces exceptions metier, et c'est la couche API (UploadedDocumentProcessor)
|
||||
* qui la traduit en reponse HTTP 422.
|
||||
*/
|
||||
class FileUploadException extends RuntimeException {}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Exception;
|
||||
|
||||
/**
|
||||
* Levee quand le type MIME detecte server-side n'appartient pas a la whitelist
|
||||
* du FileUploader (PDF + images). Traduite en HTTP 422 par le processor.
|
||||
*/
|
||||
final class UnsupportedMimeTypeException extends FileUploadException
|
||||
{
|
||||
/**
|
||||
* @param list<string> $allowed Types MIME autorises
|
||||
*/
|
||||
public function __construct(string $mimeType, array $allowed)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'Le type de fichier « %s » n\'est pas autorisé. Types acceptés : %s.',
|
||||
$mimeType,
|
||||
implode(', ', $allowed),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Shared\Domain\Exception\FileUploadException;
|
||||
use App\Shared\Infrastructure\Upload\FileUploader;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de l'upload generique (POST /api/uploaded_documents).
|
||||
*
|
||||
* L'operation Post est en `deserialize: false` : le binaire n'est pas mappe sur
|
||||
* l'entite. Ce processor lit le fichier multipart de la requete (champ « file »),
|
||||
* delegue au FileUploader (validation MIME server-side, bornage taille, checksum,
|
||||
* ecriture disque), positionne l'auteur (created_by) puis persiste via le
|
||||
* processor Doctrine standard. Le retour est l'entite, qu'API Platform serialise
|
||||
* en JSON-LD (avec son @id / IRI).
|
||||
*
|
||||
* Mapping des erreurs :
|
||||
* - fichier absent -> 422 ;
|
||||
* - MIME hors whitelist / fichier trop volumineux (FileUploadException) -> 422.
|
||||
*
|
||||
* Si la persistance Doctrine echoue APRES l'ecriture disque, le fichier physique
|
||||
* deja deplace est supprime (compensation) pour ne pas laisser de binaire orphelin.
|
||||
*
|
||||
* @implements ProcessorInterface<mixed, mixed>
|
||||
*/
|
||||
final class UploadedDocumentProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly FileUploader $fileUploader,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$file = $request?->files->get('file');
|
||||
|
||||
if (!$file instanceof UploadedFile) {
|
||||
throw new UnprocessableEntityHttpException('Aucun fichier fourni (champ « file » attendu).');
|
||||
}
|
||||
|
||||
try {
|
||||
$document = $this->fileUploader->upload($file);
|
||||
} catch (FileUploadException $e) {
|
||||
// MIME hors whitelist ou fichier trop volumineux -> 422 avec le
|
||||
// message metier explicite porte par l'exception.
|
||||
throw new UnprocessableEntityHttpException($e->getMessage(), $e);
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof UserInterface) {
|
||||
$document->setCreatedBy($user);
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->persistProcessor->process($document, $operation, $uriVariables, $context);
|
||||
} catch (Throwable $e) {
|
||||
// La persistance a echoue APRES l'ecriture disque (erreur DB, FK...) :
|
||||
// on supprime le fichier orphelin pour ne pas le laisser sans ligne
|
||||
// uploaded_document correspondante, puis on relaie l'erreur.
|
||||
$this->fileUploader->remove($document);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,18 +36,6 @@ final class ColumnCommentsCatalog
|
||||
public static function comments(): array
|
||||
{
|
||||
return [
|
||||
'uploaded_document' => [
|
||||
'_table' => 'Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'original_filename' => 'Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.',
|
||||
'stored_path' => 'Chemin relatif du fichier sous var/uploads (ex: 2026/06/<hash>.pdf) — nom genere aleatoirement, jamais le nom client.',
|
||||
'mime_type' => 'Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).',
|
||||
'size_bytes' => 'Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.',
|
||||
'checksum' => 'Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).',
|
||||
'created_at' => 'Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).',
|
||||
'created_by' => 'ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.',
|
||||
],
|
||||
|
||||
'audit_log' => [
|
||||
'_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.",
|
||||
'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).",
|
||||
@@ -407,12 +395,12 @@ final class ColumnCommentsCatalog
|
||||
],
|
||||
|
||||
'provider_contact' => [
|
||||
'_table' => 'Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).',
|
||||
'_table' => 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/telephone/email (RG-3.04, chk_provider_contact_name).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.',
|
||||
'first_name' => 'Prenom du contact (capitalise serveur). 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).',
|
||||
'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).',
|
||||
'last_name' => 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).',
|
||||
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
|
||||
'phone_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).',
|
||||
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).',
|
||||
'email' => 'Email du contact (lowercase serveur).',
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Upload;
|
||||
|
||||
use App\Shared\Domain\Entity\UploadedDocument;
|
||||
use App\Shared\Domain\Exception\FileTooLargeException;
|
||||
use App\Shared\Domain\Exception\FileUploadException;
|
||||
use App\Shared\Domain\Exception\UnsupportedMimeTypeException;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
/**
|
||||
* Service generique de televersement de fichiers (infra Shared, ERP-154).
|
||||
*
|
||||
* Responsabilites :
|
||||
* - Validation du type MIME SERVER-SIDE via `getMimeType()` (detection finfo
|
||||
* sur le contenu reel) — JAMAIS `getClientMimeType()`, spoofable par le
|
||||
* client (regle backend.md « Upload de fichiers »).
|
||||
* - Whitelist MIME explicite (PDF + images courantes).
|
||||
* - Bornage de la taille (MAX_SIZE_BYTES).
|
||||
* - Calcul du checksum sha256 (controle d integrite) AVANT le deplacement.
|
||||
* - Ecriture disque sous `var/uploads/{yyyy}/{mm}/` avec un nom genere
|
||||
* aleatoirement (jamais le nom client, qui reste une simple metadonnee).
|
||||
*
|
||||
* Le service est volontairement decouple de HTTP au-dela du type UploadedFile :
|
||||
* il leve des exceptions metier (FileUploadException), traduites en 422 par le
|
||||
* UploadedDocumentProcessor.
|
||||
*/
|
||||
final class FileUploader
|
||||
{
|
||||
/**
|
||||
* Types MIME autorises (detectes server-side) : PDF + images courantes.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const ALLOWED_MIME_TYPES = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
];
|
||||
|
||||
/**
|
||||
* Taille maximale autorisee : 10 Mo.
|
||||
*/
|
||||
public const MAX_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
public function __construct(
|
||||
// Racine de stockage des fichiers televerses (hors web root, sous var/).
|
||||
#[Autowire('%kernel.project_dir%/var/uploads')]
|
||||
private readonly string $uploadBaseDir,
|
||||
private readonly ClockInterface $clock,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Valide, calcule l empreinte, deplace le fichier sur disque et retourne
|
||||
* un UploadedDocument NON persiste (le caller le persiste).
|
||||
*
|
||||
* @throws UnsupportedMimeTypeException si le MIME server-side est hors whitelist
|
||||
* @throws FileTooLargeException si le fichier depasse MAX_SIZE_BYTES
|
||||
*/
|
||||
public function upload(UploadedFile $file): UploadedDocument
|
||||
{
|
||||
// Detection MIME server-side (finfo sur le contenu) — jamais le MIME
|
||||
// declare par le client.
|
||||
$mimeType = $file->getMimeType() ?? 'application/octet-stream';
|
||||
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
|
||||
throw new UnsupportedMimeTypeException($mimeType, self::ALLOWED_MIME_TYPES);
|
||||
}
|
||||
|
||||
// getSize() peut renvoyer false si le fichier est illisible.
|
||||
$size = $file->getSize();
|
||||
if (false === $size || $size > self::MAX_SIZE_BYTES) {
|
||||
throw new FileTooLargeException(false === $size ? 0 : $size, self::MAX_SIZE_BYTES);
|
||||
}
|
||||
|
||||
// Checksum AVANT le move : le chemin du fichier change apres deplacement.
|
||||
// hash_file renvoie false si le fichier temporaire est illisible (I/O) :
|
||||
// on echoue proprement plutot que de propager un TypeError opaque au
|
||||
// constructeur (parametre $checksum type string).
|
||||
$checksum = hash_file('sha256', $file->getPathname());
|
||||
if (false === $checksum) {
|
||||
throw new FileUploadException('Impossible de lire le fichier televerse pour en calculer l\'empreinte.');
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
$relativeDir = $now->format('Y').'/'.$now->format('m');
|
||||
$targetDir = $this->uploadBaseDir.'/'.$relativeDir;
|
||||
|
||||
// Nom de stockage genere aleatoirement : evite les collisions et toute
|
||||
// injection via le nom client. Extension deduite du MIME.
|
||||
$extension = $file->guessExtension() ?: 'bin';
|
||||
$storedName = bin2hex(random_bytes(16)).'.'.$extension;
|
||||
|
||||
// Le nom d origine est conserve uniquement comme metadonnee d affichage,
|
||||
// borne a la longueur de colonne (255).
|
||||
$originalFilename = mb_substr($file->getClientOriginalName(), 0, 255);
|
||||
|
||||
$file->move($targetDir, $storedName);
|
||||
|
||||
return new UploadedDocument(
|
||||
originalFilename: $originalFilename,
|
||||
storedPath: $relativeDir.'/'.$storedName,
|
||||
mimeType: $mimeType,
|
||||
sizeBytes: $size,
|
||||
checksum: $checksum,
|
||||
createdAt: $now,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime le fichier physique d'un document (compensation lorsque la
|
||||
* persistance echoue APRES l'ecriture disque). Best-effort : silencieux si
|
||||
* le fichier a deja disparu. Evite d'accumuler des binaires orphelins non
|
||||
* references en base.
|
||||
*/
|
||||
public function remove(UploadedDocument $document): void
|
||||
{
|
||||
$path = $this->uploadBaseDir.'/'.$document->getStoredPath();
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Entity\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;
|
||||
|
||||
/**
|
||||
* Base des tests fonctionnels du repertoire prestataires (M3 — module Technique).
|
||||
* Jumelle de la base fournisseurs (M2), recentree sur le perimetre ERP-134
|
||||
* (Provider + Processor + cloisonnement site).
|
||||
*
|
||||
* Donnees (RETEX M1/M2 — pas de fixtures globales pour les tests) : chaque test
|
||||
* seede ses prestataires en base via les helpers ci-dessous, puis le tearDown les
|
||||
* purge. Les 3 sites (Chatellerault 86 / Saint-Jean 17 / Pommevic 82) sont seedes
|
||||
* par SitesFixtures (make test-db-setup) ; on les recupere par code postal.
|
||||
*
|
||||
* Categories : `providerCategory('NETTOYAGE')` fetch-or-create une categorie de
|
||||
* type PRESTATAIRE (requis par RG-3.09). Pour fabriquer une categorie d'un AUTRE
|
||||
* type (test de rejet RG-3.09), utiliser `foreignCategory()`.
|
||||
*
|
||||
* Cleanup : tearDown purge prestataires AVANT categories/users (provider_category
|
||||
* et provider_site sont ON DELETE CASCADE cote provider — le DELETE DQL sur
|
||||
* Provider libere categories et sites pour les purges suivantes).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class AbstractProviderApiTestCase extends AbstractApiTestCase
|
||||
{
|
||||
protected const string LD = 'application/ld+json';
|
||||
protected const string MERGE = 'application/merge-patch+json';
|
||||
|
||||
protected const string TEST_CATEGORY_PREFIX = 'test_prov_cat_';
|
||||
|
||||
/** Codes postaux des 3 sites fixtures (cf. SitesFixtures). */
|
||||
protected const string SITE_86 = '86100'; // Chatellerault
|
||||
protected const string SITE_17 = '17400'; // Saint-Jean
|
||||
protected const string SITE_82 = '82400'; // Pommevic
|
||||
|
||||
/** IBAN / BIC valides (memes valeurs que les tests M2) pour les RIB. */
|
||||
protected const string VALID_IBAN = 'FR1420041010050500013M02606';
|
||||
protected const string VALID_BIC = 'BNPAFRPPXXX';
|
||||
/** BIC d'un autre pays (DE) : controle croise pays BIC/IBAN. */
|
||||
protected const string FOREIGN_BIC = 'DEUTDEFFXXX';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
$em->createQuery('DELETE FROM '.Provider::class)->execute();
|
||||
$em->createQuery('DELETE FROM '.Category::class.' c WHERE c.name LIKE :prefix')
|
||||
->setParameter('prefix', self::TEST_CATEGORY_PREFIX.'%')->execute()
|
||||
;
|
||||
$em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix')
|
||||
->setParameter('prefix', 'test_%')->execute()
|
||||
;
|
||||
$em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix')
|
||||
->setParameter('prefix', 'test_%')->execute()
|
||||
;
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
protected function createAdminClient(): Client
|
||||
{
|
||||
return $this->authenticatedClient('admin', 'admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere (ou cree) le type PRESTATAIRE. Idempotent (unicite category_type.code).
|
||||
*/
|
||||
protected function providerCategoryType(): CategoryType
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'PRESTATAIRE']);
|
||||
if (null !== $existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$type = new CategoryType();
|
||||
$type->setCode('PRESTATAIRE');
|
||||
$type->setLabel('Prestataire');
|
||||
$em->persist($type);
|
||||
$em->flush();
|
||||
|
||||
return $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch-or-create une categorie de type PRESTATAIRE par code (defaut NETTOYAGE).
|
||||
* Idempotent (lookup par code, aligne sur l'index unique partiel uq_category_code)
|
||||
* et auto-suffisant. Nom prefixe -> purge par tearDown.
|
||||
*/
|
||||
protected function providerCategory(string $code = 'NETTOYAGE'): Category
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$existing = $em->getRepository(Category::class)->findOneBy(['code' => $code, 'deletedAt' => null]);
|
||||
if (null !== $existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$category = new Category();
|
||||
$category->setName(self::TEST_CATEGORY_PREFIX.strtolower($code));
|
||||
$category->setCode($code);
|
||||
$category->addCategoryType($this->providerCategoryType());
|
||||
$em->persist($category);
|
||||
$em->flush();
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree une categorie d'un type DIFFERENT de PRESTATAIRE (pour tester le rejet
|
||||
* RG-3.09). Code unique pour ne pas collisionner avec une categorie existante.
|
||||
*/
|
||||
protected function foreignCategory(): Category
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
|
||||
$type = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'CLIENT']);
|
||||
if (null === $type) {
|
||||
$type = new CategoryType();
|
||||
$type->setCode('CLIENT');
|
||||
$type->setLabel('Client');
|
||||
$em->persist($type);
|
||||
}
|
||||
|
||||
$category = new Category();
|
||||
$category->setName(self::TEST_CATEGORY_PREFIX.'foreign_'.$suffix);
|
||||
$category->setCode('FOREIGN_'.strtoupper($suffix));
|
||||
$category->addCategoryType($type);
|
||||
$em->persist($category);
|
||||
$em->flush();
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere un site fixture par code postal (cf. SitesFixtures). Echoue
|
||||
* explicitement si absent (fixtures non chargees / module Sites off).
|
||||
*/
|
||||
protected function site(string $postalCode): Site
|
||||
{
|
||||
$site = $this->getEm()->getRepository(Site::class)->findOneBy(['postalCode' => $postalCode]);
|
||||
|
||||
self::assertNotNull(
|
||||
$site,
|
||||
sprintf('Site fixture "%s" introuvable : SitesFixtures charge (make test-db-setup) ?', $postalCode),
|
||||
);
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede directement un Provider minimal (sans passer par l'API), pour les tests
|
||||
* de liste / archivage / cloisonnement. Nom stocke en MAJUSCULES pour refleter
|
||||
* l'etat normalise (RG-3.11) qu'aurait produit le ProviderProcessor. Porte une
|
||||
* categorie PRESTATAIRE + les sites donnes (par code postal).
|
||||
*
|
||||
* @param list<string> $sitePostalCodes codes postaux des sites a rattacher
|
||||
*/
|
||||
protected function seedProvider(
|
||||
string $companyName,
|
||||
array $sitePostalCodes = [self::SITE_86],
|
||||
bool $isArchived = false,
|
||||
string $categoryCode = 'NETTOYAGE',
|
||||
?string $siren = null,
|
||||
): Provider {
|
||||
$em = $this->getEm();
|
||||
$provider = new Provider();
|
||||
$provider->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
|
||||
$provider->addCategory($this->providerCategory($categoryCode));
|
||||
foreach ($sitePostalCodes as $postalCode) {
|
||||
$provider->addSite($this->site($postalCode));
|
||||
}
|
||||
if (null !== $siren) {
|
||||
$provider->setSiren($siren);
|
||||
}
|
||||
$provider->setIsArchived($isArchived);
|
||||
if ($isArchived) {
|
||||
$provider->setArchivedAt(new DateTimeImmutable());
|
||||
}
|
||||
$em->persist($provider);
|
||||
$em->flush();
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload minimal valide du formulaire principal (companyName + 1 categorie
|
||||
* PRESTATAIRE + sites donnes). Categorie NETTOYAGE par defaut.
|
||||
*
|
||||
* @param list<string> $sitePostalCodes
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function validMainPayload(string $companyName, array $sitePostalCodes = [self::SITE_86]): array
|
||||
{
|
||||
$siteIris = array_map(fn (string $pc): string => '/api/sites/'.$this->site($pc)->getId(), $sitePostalCodes);
|
||||
|
||||
return [
|
||||
'companyName' => $companyName,
|
||||
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
|
||||
'sites' => $siteIris,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un utilisateur non-admin CLOISONNE : porte les permissions donnees via
|
||||
* un role jetable, rattache aux seuls sites donnes (par code postal), avec un
|
||||
* currentSite positionne. N'a PAS `sites.bypass_scope` (sauf si fourni dans
|
||||
* $permissionCodes) -> sujet ideal des tests de cloisonnement (RG-3.17).
|
||||
*
|
||||
* Contrairement a createUserWithPermissions() (parent, qui attache TOUS les
|
||||
* sites et ne pose pas de currentSite), ce helper controle finement le
|
||||
* perimetre site de l'user.
|
||||
*
|
||||
* @param list<string> $permissionCodes
|
||||
* @param list<string> $sitePostalCodes sites a rattacher (user_site)
|
||||
*
|
||||
* @return array{username: string, password: string}
|
||||
*/
|
||||
protected function createScopedUser(
|
||||
array $permissionCodes,
|
||||
array $sitePostalCodes,
|
||||
?string $currentSitePostalCode = null,
|
||||
): array {
|
||||
$em = $this->getEm();
|
||||
|
||||
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
$username = 'test_scoped_'.$suffix;
|
||||
$password = 'testpass';
|
||||
|
||||
/** @var UserPasswordHasherInterface $hasher */
|
||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
|
||||
$role = new Role('test_'.$suffix, 'Test Role '.$suffix, false);
|
||||
foreach ($permissionCodes as $code) {
|
||||
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => $code]);
|
||||
self::assertNotNull($permission, sprintf('Permission "%s" introuvable (app:sync-permissions ?).', $code));
|
||||
$role->addPermission($permission);
|
||||
}
|
||||
$em->persist($role);
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername($username);
|
||||
$user->setIsAdmin(false);
|
||||
$user->setPassword($hasher->hashPassword($user, $password));
|
||||
$user->addRbacRole($role);
|
||||
|
||||
foreach ($sitePostalCodes as $postalCode) {
|
||||
$user->addSite($this->site($postalCode));
|
||||
}
|
||||
if (null !== $currentSitePostalCode) {
|
||||
$user->setCurrentSite($this->site($currentSitePostalCode));
|
||||
}
|
||||
|
||||
$em->persist($user);
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
|
||||
return ['username' => $username, 'password' => $password];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un contact a un prestataire deja persiste (seed direct).
|
||||
*/
|
||||
protected function addContact(
|
||||
Provider $provider,
|
||||
?string $firstName = 'Marie',
|
||||
?string $lastName = 'Martin',
|
||||
?string $phonePrimary = null,
|
||||
?string $email = null,
|
||||
int $position = 0,
|
||||
): ProviderContact {
|
||||
$contact = new ProviderContact();
|
||||
$contact->setProvider($provider);
|
||||
$contact->setFirstName($firstName);
|
||||
$contact->setLastName($lastName);
|
||||
$contact->setPhonePrimary($phonePrimary);
|
||||
$contact->setEmail($email);
|
||||
$contact->setPosition($position);
|
||||
$provider->addContact($contact);
|
||||
$this->getEm()->persist($contact);
|
||||
$this->getEm()->flush();
|
||||
|
||||
return $contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un RIB a un prestataire deja persiste (seed direct).
|
||||
*/
|
||||
protected function addRib(Provider $provider, string $label = 'Compte principal'): ProviderRib
|
||||
{
|
||||
$rib = new ProviderRib();
|
||||
$rib->setProvider($provider);
|
||||
$rib->setLabel($label);
|
||||
$rib->setBic(self::VALID_BIC);
|
||||
$rib->setIban(self::VALID_IBAN);
|
||||
$provider->addRib($rib);
|
||||
$this->getEm()->persist($rib);
|
||||
$this->getEm()->flush();
|
||||
|
||||
return $rib;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* @param array<string, mixed> $body corps decode (toArray(false))
|
||||
*
|
||||
* @return array<string, string> propertyPath => message
|
||||
*/
|
||||
protected function violationsByPath(array $body): array
|
||||
{
|
||||
$byPath = [];
|
||||
foreach ($body['violations'] ?? [] as $v) {
|
||||
$byPath[$v['propertyPath']] = $v['message'];
|
||||
}
|
||||
|
||||
return $byPath;
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels des RG comptables inter-champs portees par les Assert\Callback
|
||||
* de l'entite Provider (M3, RG-3.07 / RG-3.08), via le PATCH de l'onglet
|
||||
* Comptabilite (groupe provider:write:accounting). On asserte le code HTTP et le
|
||||
* propertyPath de la violation (consommable par extractApiViolations cote front,
|
||||
* ERP-101). Jumeau de SupplierAccountingApiTest (M2), 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.
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels du formulaire principal prestataire (POST + PATCH) — ERP-134.
|
||||
* Couvre : creation (RG-3.03 sites obligatoires, RG-3.09 type categorie),
|
||||
* normalisation companyName (RG-3.11), 409 doublon (RG-3.10).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderApiTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
public function testPostMainCreatesProvider(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Maintenance Pro', [self::SITE_86]),
|
||||
]);
|
||||
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
$body = $response->toArray();
|
||||
// RG-3.11 : companyName normalise en MAJUSCULES.
|
||||
self::assertSame('MAINTENANCE PRO', $body['companyName']);
|
||||
self::assertArrayHasKey('id', $body);
|
||||
// sites embarque (relation directe, site:read) avec name/postalCode.
|
||||
self::assertCount(1, $body['sites']);
|
||||
self::assertSame('86100', $body['sites'][0]['postalCode']);
|
||||
}
|
||||
|
||||
public function testPostWithoutSiteIsRejected(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$payload = $this->validMainPayload('Sans Site', [self::SITE_86]);
|
||||
$payload['sites'] = [];
|
||||
|
||||
$response = $client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $payload,
|
||||
]);
|
||||
|
||||
// RG-3.03 : au moins un site obligatoire.
|
||||
self::assertSame(422, $response->getStatusCode());
|
||||
self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
public function testPostWithoutCategoryIsRejected(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$payload = $this->validMainPayload('Sans Categorie', [self::SITE_86]);
|
||||
$payload['categories'] = [];
|
||||
|
||||
$response = $client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $payload,
|
||||
]);
|
||||
|
||||
// RG-3.09 : au moins une categorie obligatoire.
|
||||
self::assertSame(422, $response->getStatusCode());
|
||||
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
public function testPostWithForeignCategoryTypeIsRejected(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$foreign = $this->foreignCategory();
|
||||
|
||||
$payload = $this->validMainPayload('Mauvais Type', [self::SITE_86]);
|
||||
$payload['categories'] = ['/api/categories/'.$foreign->getId()];
|
||||
|
||||
$response = $client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $payload,
|
||||
]);
|
||||
|
||||
// RG-3.09 : categorie hors type PRESTATAIRE -> 422 sur `categories`.
|
||||
self::assertSame(422, $response->getStatusCode());
|
||||
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
public function testDuplicateCompanyNameReturns409(): void
|
||||
{
|
||||
$this->seedProvider('Doublon Sarl', [self::SITE_86]);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
// Casse differente : l'unicite est insensible a la casse (LOWER).
|
||||
'json' => $this->validMainPayload('doublon sarl', [self::SITE_86]),
|
||||
]);
|
||||
|
||||
// RG-3.10 : doublon de nom (case-insensitive) -> 409.
|
||||
self::assertSame(409, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testSameNameAfterArchiveIsAllowed(): void
|
||||
{
|
||||
// Index partiel : l'unicite ignore les archives -> reutilisation du nom OK.
|
||||
$this->seedProvider('Recyclage Express', [self::SITE_86], isArchived: true);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Recyclage Express', [self::SITE_86]),
|
||||
]);
|
||||
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
<?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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'export XLSX du repertoire prestataires (M3, § 4.6).
|
||||
* Jumeau du {@see \App\Tests\Module\Commercial\Api\SupplierExportControllerTest}
|
||||
* (M2), augmente du cloisonnement par site (§ 2.13, propre au M3).
|
||||
*
|
||||
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
|
||||
* archives par defaut, respect du filtre ?search, peuplement des colonnes contact
|
||||
* principal / categories / sites (relation directe provider.sites), gating de la
|
||||
* colonne SIREN selon technique.providers.accounting.view (admin ET user minimal a
|
||||
* permission explicite), dedup (prestataire multi-categories rendu sur une seule
|
||||
* ligne), cloisonnement par site (un user cloisonne n'exporte que son site), 403
|
||||
* sans technique.providers.view, 401 anonyme.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderExportControllerTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
private const string EXPORT_URL = '/api/providers/export.xlsx';
|
||||
|
||||
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProvider('Export Alpha');
|
||||
|
||||
$response = $client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$headers = $response->getHeaders(false);
|
||||
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
|
||||
|
||||
$disposition = $headers['content-disposition'][0] ?? '';
|
||||
self::assertStringContainsString('attachment; filename="repertoire-prestataires-', $disposition);
|
||||
self::assertMatchesRegularExpression(
|
||||
'/filename="repertoire-prestataires-\d{8}\.xlsx"/',
|
||||
$disposition,
|
||||
);
|
||||
|
||||
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
|
||||
$grid = $this->gridFromResponse($response->getContent());
|
||||
$headers = $grid[0];
|
||||
self::assertSame('Nom prestataire', $headers[0]);
|
||||
self::assertContains('Contact principal', $headers);
|
||||
self::assertContains('Téléphone principal', $headers);
|
||||
self::assertContains('Téléphone secondaire', $headers);
|
||||
self::assertContains('Email', $headers);
|
||||
self::assertContains('Catégories', $headers);
|
||||
self::assertContains('Sites', $headers);
|
||||
self::assertContains('Date de création', $headers);
|
||||
}
|
||||
|
||||
public function testExportExcludesArchivedByDefault(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProvider('Active One');
|
||||
$this->seedProvider('Archived One', [self::SITE_86], true);
|
||||
|
||||
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('ACTIVE ONE', $names);
|
||||
self::assertNotContains('ARCHIVED ONE', $names);
|
||||
}
|
||||
|
||||
public function testExportRespectsSearchFilter(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProvider('Searchable Alpha');
|
||||
$this->seedProvider('Other Beta');
|
||||
|
||||
$names = $this->companyNames(
|
||||
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
|
||||
);
|
||||
|
||||
self::assertContains('SEARCHABLE ALPHA', $names);
|
||||
self::assertNotContains('OTHER BETA', $names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les colonnes contact sont alimentees par le CONTACT PRINCIPAL : le contact
|
||||
* de plus petit `position` (decision D2, § 4.6). On seede deux contacts en
|
||||
* ordre de position inverse pour garantir que c'est bien le principal (et non
|
||||
* le premier insere) qui alimente la ligne.
|
||||
*/
|
||||
public function testExportUsesPrincipalContactColumns(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$provider = $this->seedProvider('Contact Co');
|
||||
|
||||
// position 1 (secondaire) insere en premier...
|
||||
$this->addContact($provider, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1);
|
||||
// ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
|
||||
$principal = $this->addContact($provider, 'Alice', 'Principal', '0612345678', 'alice@contact.co', 0);
|
||||
// Le telephone secondaire n'est pas porte par le helper de base : on le pose
|
||||
// directement sur le contact principal pour alimenter la colonne dediee.
|
||||
$principal->setPhoneSecondary('0698765432');
|
||||
$this->getEm()->flush();
|
||||
|
||||
$row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO');
|
||||
|
||||
self::assertNotNull($row, 'Ligne « CONTACT CO » introuvable dans l\'export.');
|
||||
self::assertSame('Principal Alice', $row[1]);
|
||||
self::assertSame('0612345678', $row[2]);
|
||||
self::assertSame('0698765432', $row[3]);
|
||||
self::assertSame('alice@contact.co', $row[4]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes « Catégories » et « Sites » : un oubli d'hydratation les rendrait
|
||||
* vides sans erreur (cf. ERP-100 cote client). Le site est porte EN DIRECT par
|
||||
* le prestataire (RG-3.03), contrairement au fournisseur M2 (via l'adresse).
|
||||
*/
|
||||
public function testExportPopulatesCategoryAndSiteColumns(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProvider('Hydrate Co', [self::SITE_86], false, 'NETTOYAGE');
|
||||
|
||||
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
|
||||
|
||||
// Colonne « Catégories » : libelle de la categorie PRESTATAIRE (getName()).
|
||||
// Derive du helper de base (idempotent) plutot que de hardcoder le prefixe.
|
||||
self::assertStringContainsString((string) $this->providerCategory('NETTOYAGE')->getName(), $flat);
|
||||
// Colonne « Sites » : site rattache en direct au prestataire (RG-3.03).
|
||||
self::assertStringContainsString((string) $this->site(self::SITE_86)->getName(), $flat);
|
||||
}
|
||||
|
||||
public function testSirenColumnPresentWithAccountingView(): void
|
||||
{
|
||||
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProvider('Siren Co', [self::SITE_86], false, 'NETTOYAGE', '123456789');
|
||||
|
||||
$grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('SIREN', $grid[0]);
|
||||
self::assertStringContainsString('123456789', $this->flatten($grid));
|
||||
}
|
||||
|
||||
public function testSirenColumnAbsentWithoutAccountingView(): void
|
||||
{
|
||||
// Seed via admin, puis relecture par un user qui n'a QUE providers.view.
|
||||
$this->createAdminClient();
|
||||
$this->seedProvider('No Siren Co', [self::SITE_86], false, 'NETTOYAGE', '987654321');
|
||||
|
||||
$creds = $this->createUserWithPermission('technique.providers.view');
|
||||
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertNotContains('SIREN', $grid[0]);
|
||||
self::assertStringNotContainsString('987654321', $this->flatten($grid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) :
|
||||
* un user minimal portant uniquement technique.providers.view +
|
||||
* technique.providers.accounting.view voit bien la colonne SIREN et sa valeur.
|
||||
* Complement de testSirenColumnPresentWithAccountingView (admin), qui ne prouve
|
||||
* pas que accounting.view SEULE suffit (l'admin bypasse le RBAC). Le pendant
|
||||
* negatif est couvert par testSirenColumnAbsentWithoutAccountingView.
|
||||
*/
|
||||
public function testSirenColumnPresentForMinimalUserWithAccountingView(): void
|
||||
{
|
||||
// Seed via admin, puis relecture par un user non-admin a 2 permissions.
|
||||
$this->createAdminClient();
|
||||
$this->seedProvider('Gated Siren Co', [self::SITE_86], false, 'NETTOYAGE', '456789123');
|
||||
|
||||
$creds = $this->createUserWithPermissions([
|
||||
'technique.providers.view',
|
||||
'technique.providers.accounting.view',
|
||||
]);
|
||||
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('SIREN', $grid[0]);
|
||||
self::assertStringContainsString('456789123', $this->flatten($grid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedup : un prestataire portant >= 2 categories PRESTATAIRE est multiplie par
|
||||
* la jointure (selection/hydratation des collections) ; l'export doit le rendre
|
||||
* sur UNE SEULE ligne. On seede un prestataire a 2 categories et on assert qu'il
|
||||
* n'apparait qu'une fois dans la colonne « Nom prestataire ».
|
||||
*/
|
||||
public function testExportDeduplicatesProviderWithMultipleCategories(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$provider = $this->seedProvider('Multi Cat Co', [self::SITE_86], false, 'NETTOYAGE');
|
||||
// 2e categorie PRESTATAIRE sur le meme prestataire.
|
||||
$provider->addCategory($this->providerCategory('SECURITE'));
|
||||
$this->getEm()->flush();
|
||||
|
||||
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
$occurrences = count(array_filter($names, static fn (string $name): bool => 'MULTI CAT CO' === $name));
|
||||
self::assertSame(
|
||||
1,
|
||||
$occurrences,
|
||||
'Un prestataire multi-categories doit apparaitre sur une seule ligne (dedup).',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloisonnement par site (RG-3.17, § 2.13) : un user non-bypass cloisonne sur
|
||||
* le site 86 n'exporte QUE les prestataires rattaches au site 86 — les
|
||||
* prestataires des sites 17 / 82 sont exclus, comme dans la liste. Pendant
|
||||
* export de ProviderSiteScopeTest::testListIsScopedToCurrentSiteForNonBypassUser.
|
||||
*/
|
||||
public function testExportIsScopedToCurrentSiteForNonBypassUser(): void
|
||||
{
|
||||
// Pre-requis : module Sites actif (sinon currentSite = null, cloisonnement
|
||||
// no-op et ce test perd son sens).
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$this->createAdminClient();
|
||||
$this->seedProvider('Presta Site 86', [self::SITE_86]);
|
||||
$this->seedProvider('Presta Site 17', [self::SITE_17]);
|
||||
$this->seedProvider('Presta Site 82', [self::SITE_82]);
|
||||
|
||||
$creds = $this->createScopedUser(
|
||||
['technique.providers.view'],
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('PRESTA SITE 86', $names);
|
||||
self::assertNotContains('PRESTA SITE 17', $names);
|
||||
self::assertNotContains('PRESTA SITE 82', $names);
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutProvidersViewPermission(): void
|
||||
{
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testUnauthorizedWhenAnonymous(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
|
||||
*
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
private function gridFromResponse(string $binary): array
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_');
|
||||
self::assertIsString($tmp);
|
||||
file_put_contents($tmp, $binary);
|
||||
|
||||
try {
|
||||
return IOFactory::load($tmp)->getActiveSheet()->toArray();
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait la colonne « Nom prestataire » (1re colonne) des lignes de donnees.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function companyNames(string $binary): array
|
||||
{
|
||||
$grid = $this->gridFromResponse($binary);
|
||||
$rows = array_slice($grid, 1); // saute l'en-tete
|
||||
|
||||
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName.
|
||||
*
|
||||
* @return null|array<int, mixed>
|
||||
*/
|
||||
private function rowFor(string $binary, string $companyName): ?array
|
||||
{
|
||||
foreach (array_slice($this->gridFromResponse($binary), 1) as $row) {
|
||||
if ((string) ($row[0] ?? '') === $companyName) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplatit toute la grille en une chaine, pour les assertions de presence.
|
||||
*
|
||||
* @param array<int, array<int, mixed>> $grid
|
||||
*/
|
||||
private function flatten(array $grid): string
|
||||
{
|
||||
return implode('|', array_map(
|
||||
static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)),
|
||||
$grid,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
/**
|
||||
* Tests de la liste paginee /api/providers (ProviderProvider) — ERP-134.
|
||||
* Couvre : envelope Hydra, tri companyName ASC, exclusion des archives,
|
||||
* ?includeArchived (RG-3.16). Joue en admin (bypass_scope -> pas de cloisonnement).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderListTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
public function testListReturnsHydraEnvelopeSortedByName(): void
|
||||
{
|
||||
$this->seedProvider('Zeta Services', [self::SITE_86]);
|
||||
$this->seedProvider('Alpha Nettoyage', [self::SITE_86]);
|
||||
$this->seedProvider('Mu Maintenance', [self::SITE_86]);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/providers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
$body = $response->toArray();
|
||||
|
||||
// Envelope Hydra : totalItems present + member.
|
||||
self::assertSame(3, $body['totalItems']);
|
||||
$names = array_column($body['member'], 'companyName');
|
||||
// Tri companyName ASC (RG-3.16) — noms normalises en MAJUSCULES.
|
||||
self::assertSame(['ALPHA NETTOYAGE', 'MU MAINTENANCE', 'ZETA SERVICES'], $names);
|
||||
}
|
||||
|
||||
public function testListExcludesArchivedByDefault(): void
|
||||
{
|
||||
$this->seedProvider('Actif Sas', [self::SITE_86]);
|
||||
$this->seedProvider('Archive Sarl', [self::SITE_86], isArchived: true);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/providers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
$body = $response->toArray();
|
||||
self::assertSame(1, $body['totalItems']);
|
||||
self::assertSame('ACTIF SAS', $body['member'][0]['companyName']);
|
||||
}
|
||||
|
||||
public function testListIncludeArchivedReintegratesArchived(): void
|
||||
{
|
||||
$this->seedProvider('Actif Sas', [self::SITE_86]);
|
||||
$this->seedProvider('Archive Sarl', [self::SITE_86], isArchived: true);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/providers?includeArchived=true', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame(2, $response->toArray()['totalItems']);
|
||||
}
|
||||
|
||||
public function testListFiltersBySiteIdViaDirectRelation(): void
|
||||
{
|
||||
$this->seedProvider('Site 86 Only', [self::SITE_86]);
|
||||
$this->seedProvider('Site 17 Only', [self::SITE_17]);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$site17 = $this->site(self::SITE_17);
|
||||
$response = $client->request('GET', '/api/providers?siteId='.$site17->getId(), [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
$body = $response->toArray();
|
||||
self::assertSame(1, $body['totalItems']);
|
||||
self::assertSame('SITE 17 ONLY', $body['member'][0]['companyName']);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
/**
|
||||
* Matrice RBAC complete du repertoire prestataires par role metier (spec-back M3
|
||||
* § 2.9 + § 2.13, ERP-138). Valide 200/403 par verbe et par onglet pour
|
||||
* bureau / compta / commerciale / usine, le gating des champs comptables en
|
||||
* lecture (omission de cle) et le cloisonnement par site de l'Usine.
|
||||
*
|
||||
* Les comptes demo et la matrice sont seedes via la commande reelle
|
||||
* `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente —
|
||||
* pas de mock de role. Jumeau de SupplierRBACMatrixTest (M2), avec la difference
|
||||
* structurante du M3 : l'Usine n'est plus « 403 partout » mais possede
|
||||
* `technique.providers.view` en lecture seule, CLOISONNEE a son site courant
|
||||
* (pas de `sites.bypass_scope`).
|
||||
*
|
||||
* Matrice § 2.9 (ERP-138) — rappel :
|
||||
* - bureau : providers.view + manage (ni accounting, ni archive) + bypass_scope
|
||||
* - compta : providers.view + accounting.view + accounting.manage (PAS manage) + bypass_scope
|
||||
* - commerciale : providers.view + manage (PAS accounting) + bypass_scope
|
||||
* - usine : providers.view seul, SANS bypass_scope (cloisonne a son site)
|
||||
* - archive : admin seul (aucun role metier)
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderRBACMatrixTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Seed idempotent via la commande applicative (roles + matrice § 2.9 +
|
||||
// comptes demo). Exerce aussi le chemin de code prod.
|
||||
self::bootKernel();
|
||||
$application = new Application(self::$kernel);
|
||||
$application->setAutoExit(false);
|
||||
$exit = $application->run(
|
||||
new ArrayInput([
|
||||
'command' => 'app:seed-rbac',
|
||||
'--with-demo-users' => true,
|
||||
'--password' => self::PWD,
|
||||
]),
|
||||
new NullOutput(),
|
||||
);
|
||||
self::assertSame(
|
||||
0,
|
||||
$exit,
|
||||
'app:seed-rbac a echoue : les permissions technique.providers.* sont-elles synchronisees (app:sync-permissions) ?',
|
||||
);
|
||||
|
||||
self::ensureKernelShutdown();
|
||||
}
|
||||
|
||||
public function testBureauHasViewAndManageButNoAccountingNoArchive(): void
|
||||
{
|
||||
$seed = $this->seedProvider('Bureau Cible');
|
||||
$client = $this->authAs('bureau');
|
||||
|
||||
// view
|
||||
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// manage : creation OK (bypass_scope -> peut attacher le site 86)
|
||||
$client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Bureau Cree'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// manage : edition onglet principal OK
|
||||
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Bureau Renomme'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// PAS accounting : edition onglet Comptabilite refusee
|
||||
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['siren' => '123456789'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS archive : archivage refuse
|
||||
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testBureauDetailHasNoAccountingFields(): void
|
||||
{
|
||||
// Bureau a view mais PAS accounting.view : les champs comptables sont
|
||||
// ABSENTS du JSON (gating par omission, pas null).
|
||||
$provider = $this->seedProvider('Bureau Gating Co', [self::SITE_86], siren: '123456789');
|
||||
$client = $this->authAs('bureau');
|
||||
|
||||
$data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayNotHasKey('siren', $data);
|
||||
self::assertArrayNotHasKey('accountNumber', $data);
|
||||
self::assertArrayNotHasKey('nTva', $data);
|
||||
self::assertArrayNotHasKey('tvaMode', $data);
|
||||
self::assertArrayNotHasKey('paymentType', $data);
|
||||
self::assertArrayNotHasKey('ribs', $data);
|
||||
}
|
||||
|
||||
public function testComptaCanEditAccountingOnly(): void
|
||||
{
|
||||
$seed = $this->seedProvider('Compta Cible');
|
||||
$client = $this->authAs('compta');
|
||||
|
||||
// view
|
||||
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// PAS manage : creation refusee
|
||||
$client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Compta Post'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// accounting.manage : edition onglet Comptabilite OK
|
||||
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['siren' => '123456789'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// PAS manage : edition onglet principal refusee (mode strict RG-3.15)
|
||||
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Compta Renomme'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS archive : archivage refuse
|
||||
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testComptaDetailHasAccountingFields(): void
|
||||
{
|
||||
// Compta a accounting.view : siren + ribs presents dans le JSON.
|
||||
$provider = $this->seedProvider('Compta View Co', [self::SITE_86], siren: '987654321');
|
||||
$this->addRib($provider);
|
||||
$client = $this->authAs('compta');
|
||||
|
||||
$data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayHasKey('siren', $data);
|
||||
self::assertSame('987654321', $data['siren']);
|
||||
self::assertArrayHasKey('ribs', $data);
|
||||
self::assertNotEmpty($data['ribs']);
|
||||
}
|
||||
|
||||
public function testCommercialeHasViewAndManageButNoAccountingNoArchive(): void
|
||||
{
|
||||
$seed = $this->seedProvider('Commerciale Cible');
|
||||
$client = $this->authAs('commerciale');
|
||||
|
||||
// view
|
||||
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// manage : creation OK
|
||||
$client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Commerciale Cree'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// PAS accounting : edition onglet Comptabilite refusee
|
||||
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['siren' => '123456789'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS archive : archivage refuse
|
||||
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testCommercialeDetailHasNoAccountingFields(): void
|
||||
{
|
||||
$provider = $this->seedProvider('Commerciale Gating Co', [self::SITE_86], siren: '123456789');
|
||||
$client = $this->authAs('commerciale');
|
||||
|
||||
$data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayNotHasKey('siren', $data);
|
||||
self::assertArrayNotHasKey('accountNumber', $data);
|
||||
self::assertArrayNotHasKey('nTva', $data);
|
||||
self::assertArrayNotHasKey('tvaMode', $data);
|
||||
self::assertArrayNotHasKey('paymentType', $data);
|
||||
self::assertArrayNotHasKey('ribs', $data);
|
||||
}
|
||||
|
||||
public function testUsineHasReadOnlyAccessScopedToItsSite(): void
|
||||
{
|
||||
// Usine a view (lecture seule), SANS manage / accounting / archive, et
|
||||
// SANS bypass_scope -> cloisonnee a son site courant (Chatellerault,
|
||||
// site 86, pose par ensureDemoUsers).
|
||||
$inScope = $this->seedProvider('Usine InScope', [self::SITE_86]);
|
||||
$client = $this->authAs('usine');
|
||||
|
||||
// view : liste OK (pas un 403 comme au M2)
|
||||
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// view : detail d'un prestataire de SON site OK
|
||||
$client->request('GET', '/api/providers/'.$inScope->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// PAS manage : creation refusee
|
||||
$client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Usine Post'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS manage : edition onglet principal refusee
|
||||
$client->request('PATCH', '/api/providers/'.$inScope->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Renomme Par Usine'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS accounting : edition onglet Comptabilite refusee
|
||||
$client->request('PATCH', '/api/providers/'.$inScope->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['siren' => '123456789'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS archive : archivage refuse
|
||||
$client->request('PATCH', '/api/providers/'.$inScope->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testUsineCannotSeeProviderOutOfItsSite(): void
|
||||
{
|
||||
// Cloisonnement § 2.13 : un prestataire hors du site courant de l'Usine
|
||||
// (site 17, l'Usine est sur le site 86) -> 404 (ne pas reveler la ligne).
|
||||
$outOfScope = $this->seedProvider('Usine OutOfScope', [self::SITE_17]);
|
||||
$client = $this->authAs('usine');
|
||||
|
||||
$client->request('GET', '/api/providers/'.$outOfScope->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
private function authAs(string $role): Client
|
||||
{
|
||||
return $this->authenticatedClient($role, self::PWD);
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
|
||||
/**
|
||||
* Tests du gating comptabilite + mode strict par groupe (ProviderProcessor /
|
||||
* ProviderReadGroupContextBuilder) — ERP-134.
|
||||
*
|
||||
* Couvre : gating accounting PAR OMISSION (siren/ribs absents sans accounting.view,
|
||||
* bug #4 M1), mode strict RG-3.15 (403 sur tout le payload), gating archive (RG-3.13).
|
||||
*
|
||||
* Les users sont crees via createUserWithPermissions() (parent) : rattaches a TOUS
|
||||
* les sites SANS currentSite -> CurrentSiteProvider::get() = null -> aucun
|
||||
* cloisonnement, on isole ainsi le comportement RBAC du comportement site.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderRbacGatingTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
public function testAccountingFieldsOmittedWithoutAccountingView(): void
|
||||
{
|
||||
$provider = $this->seedProvider('Compta Masquee', [self::SITE_86], siren: '123456789');
|
||||
$id = $provider->getId();
|
||||
|
||||
// Profil type Commerciale : view + manage SANS accounting.view.
|
||||
$creds = $this->createUserWithPermissions(['technique.providers.view']);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$response = $client->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$body = $response->toArray();
|
||||
// Gating par omission : scalaires comptables ET ribs totalement absents.
|
||||
self::assertArrayNotHasKey('siren', $body);
|
||||
self::assertArrayNotHasKey('ribs', $body);
|
||||
// isArchived reste expose (bug #3 M1 : la cle ne doit pas etre droppee).
|
||||
self::assertArrayHasKey('isArchived', $body);
|
||||
}
|
||||
|
||||
public function testAccountingFieldsPresentWithAccountingView(): void
|
||||
{
|
||||
$provider = $this->seedProvider('Compta Visible', [self::SITE_86], siren: '987654321');
|
||||
$id = $provider->getId();
|
||||
|
||||
$creds = $this->createUserWithPermissions([
|
||||
'technique.providers.view',
|
||||
'technique.providers.accounting.view',
|
||||
]);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$response = $client->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$body = $response->toArray();
|
||||
self::assertSame('987654321', $body['siren']);
|
||||
// La cle ribs apparait (collection vide ici, mais presente).
|
||||
self::assertArrayHasKey('ribs', $body);
|
||||
}
|
||||
|
||||
public function testStrictModeRejectsMixedGroupsForManageOnlyUser(): void
|
||||
{
|
||||
$provider = $this->seedProvider('Strict Cible', [self::SITE_86]);
|
||||
$id = $provider->getId();
|
||||
|
||||
// Profil type Bureau : manage SANS accounting.manage.
|
||||
$creds = $this->createUserWithPermissions([
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
]);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$response = $client->request('PATCH', '/api/providers/'.$id, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Renomme', 'siren' => '111222333'],
|
||||
]);
|
||||
|
||||
// RG-3.15 : payload melangeant main + accounting sans accounting.manage
|
||||
// -> 403 sur tout le payload (mode strict, pas de filtrage silencieux).
|
||||
self::assertSame(403, $response->getStatusCode());
|
||||
|
||||
// Aucun champ n'a ete persiste (rollback du mode strict).
|
||||
$this->getEm()->clear();
|
||||
$reloaded = $this->getEm()->getRepository(Provider::class)->find($id);
|
||||
self::assertSame('STRICT CIBLE', $reloaded->getCompanyName());
|
||||
self::assertNull($reloaded->getSiren());
|
||||
}
|
||||
|
||||
public function testAccountingOnlyUserCanPatchAccountingButNotMain(): void
|
||||
{
|
||||
$provider = $this->seedProvider('Compta Editrice', [self::SITE_86]);
|
||||
$id = $provider->getId();
|
||||
|
||||
// Profil type Compta : accounting.view + accounting.manage SANS manage.
|
||||
$creds = $this->createUserWithPermissions([
|
||||
'technique.providers.view',
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
]);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
// PATCH accounting -> 200.
|
||||
$ok = $client->request('PATCH', '/api/providers/'.$id, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['siren' => '555666777'],
|
||||
]);
|
||||
self::assertSame(200, $ok->getStatusCode());
|
||||
|
||||
// PATCH main (companyName) -> 403 (pas de permission manage).
|
||||
$ko = $client->request('PATCH', '/api/providers/'.$id, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Interdit'],
|
||||
]);
|
||||
self::assertSame(403, $ko->getStatusCode());
|
||||
}
|
||||
|
||||
public function testArchiveRequiresArchivePermission(): void
|
||||
{
|
||||
$provider = $this->seedProvider('A Archiver', [self::SITE_86]);
|
||||
$id = $provider->getId();
|
||||
|
||||
// Bureau (manage) sans archive -> 403.
|
||||
$creds = $this->createUserWithPermissions([
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
]);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$response = $client->request('PATCH', '/api/providers/'.$id, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
|
||||
// RG-3.13 : l'archivage exige technique.providers.archive.
|
||||
self::assertSame(403, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testAdminCanArchiveAndSetsArchivedAt(): void
|
||||
{
|
||||
$provider = $this->seedProvider('Archivable', [self::SITE_86]);
|
||||
$id = $provider->getId();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('PATCH', '/api/providers/'.$id, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$this->getEm()->clear();
|
||||
$reloaded = $this->getEm()->getRepository(Provider::class)->find($id);
|
||||
self::assertTrue($reloaded->isArchived());
|
||||
self::assertNotNull($reloaded->getArchivedAt());
|
||||
}
|
||||
|
||||
public function testRestoreWithNameConflictReturns409(): void
|
||||
{
|
||||
// Un prestataire archive porte un nom qu'un prestataire ACTIF a repris
|
||||
// entre-temps (autorise par l'index partiel : l'archive n'y figure pas).
|
||||
$archived = $this->seedProvider('Conflit Co', [self::SITE_86], isArchived: true);
|
||||
$this->seedProvider('Conflit Co', [self::SITE_86]); // actif, meme nom normalise
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('PATCH', '/api/providers/'.$archived->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => false],
|
||||
]);
|
||||
|
||||
// RG-3.14 : restaurer ferait deux actifs homonymes -> 409 (pas de 500 SQL).
|
||||
self::assertSame(409, $response->getStatusCode());
|
||||
}
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
/**
|
||||
* Tests anti-regression du CONTRAT DE SERIALISATION du repertoire prestataires
|
||||
* (M3, spec-back § 4.0 / § 4.0.bis). Jumeau du
|
||||
* {@see \App\Tests\Module\Commercial\Api\SupplierSerializationContractTest} (M2),
|
||||
* il reverifie sur le JSON REEL les pieges silencieux herites du M1/M2 :
|
||||
* - #4 : fuite RIB (IBAN/BIC) vers un user sans accounting.view -> cle `ribs`
|
||||
* ABSENTE pour un profil type Commerciale (gating par omission).
|
||||
* - #3 : booleens droppes (Groups sur la propriete `isX`, getter derivant `x`)
|
||||
* -> isArchived present dans le detail.
|
||||
* - #1 : categories embarquees sans code/name -> code + name presents en LISTE
|
||||
* ET DETAIL (provider ET adresse).
|
||||
* - #2 : sites embarques en IRI nu -> name + postalCode presents en LISTE
|
||||
* (relation DIRECTE provider.sites — RG-3.03) ET DETAIL (addresses[].sites[]).
|
||||
* - ERP-92 : refs comptables (tvaMode/paymentDelay/paymentType/bank) embarquees
|
||||
* {id, code, label} et non IRI nu (le groupe provider:read:accounting doit
|
||||
* etre porte par les entites partagees — fix ERP-139, sinon IRI nu).
|
||||
* Plus l'enveloppe AP4 (member/totalItems/view sans prefixe hydra:, archives exclus).
|
||||
*
|
||||
* REGLE D'OR : ces tests assertent sur le CORPS JSON reel, jamais sur les
|
||||
* annotations. Toute regression de groupe de serialisation casse ici.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderSerializationContractTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
// === #4 — Gating des RIB par accounting.view ===
|
||||
|
||||
public function testRibsPresentForAdminWithAccountingView(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Rib Admin Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Admin bypass RBAC -> accounting.view -> RIB embarques (label/bic/iban).
|
||||
self::assertArrayHasKey('ribs', $data);
|
||||
self::assertNotEmpty($data['ribs']);
|
||||
self::assertSame('Compte principal', $data['ribs'][0]['label']);
|
||||
self::assertSame(self::VALID_IBAN, $data['ribs'][0]['iban']);
|
||||
self::assertSame(self::VALID_BIC, $data['ribs'][0]['bic']);
|
||||
}
|
||||
|
||||
public function testRibsAbsentForUserWithoutAccountingView(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Rib Commerciale Co');
|
||||
|
||||
// Profil type Commerciale : technique.providers.view SANS accounting.view.
|
||||
// createUserWithPermissions n'attache pas de currentSite -> pas de
|
||||
// cloisonnement, on isole le gating comptable du comportement site.
|
||||
$creds = $this->createUserWithPermissions(['technique.providers.view']);
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// La cle `ribs` est ABSENTE (pas null) : le groupe provider:read:accounting
|
||||
// n'est pas ajoute au contexte -> getRibs() jamais serialise. Fin de la
|
||||
// fuite IBAN/BIC (piege n°4 du M1).
|
||||
self::assertArrayNotHasKey('ribs', $data);
|
||||
}
|
||||
|
||||
// === #4.bis — Gating par OMISSION des scalaires comptables ===
|
||||
|
||||
public function testAccountingScalarsGatedByOmission(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Compta Gating Co');
|
||||
$id = $provider->getId();
|
||||
|
||||
// Admin : scalaires comptables presents.
|
||||
$admin = $this->createAdminClient();
|
||||
$adminData = $admin->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertArrayHasKey('siren', $adminData);
|
||||
self::assertSame('987654321', $adminData['siren']);
|
||||
self::assertArrayHasKey('accountNumber', $adminData);
|
||||
self::assertArrayHasKey('paymentType', $adminData);
|
||||
|
||||
// Sans accounting.view : scalaires comptables ABSENTS (omission, pas null).
|
||||
$creds = $this->createUserWithPermissions(['technique.providers.view']);
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
$data = $http->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayNotHasKey('siren', $data);
|
||||
self::assertArrayNotHasKey('accountNumber', $data);
|
||||
self::assertArrayNotHasKey('nTva', $data);
|
||||
self::assertArrayNotHasKey('tvaMode', $data);
|
||||
self::assertArrayNotHasKey('paymentType', $data);
|
||||
self::assertArrayNotHasKey('ribs', $data);
|
||||
}
|
||||
|
||||
// === ERP-139 — Refs comptables embarquees {id, code, label} et non IRI nu ===
|
||||
|
||||
public function testAccountingReferentialsEmbedIdCodeLabel(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
// Reglement VIREMENT -> banque renseignee : on couvre les 4 referentiels.
|
||||
$provider = $this->seedCompleteProvider('Refs Embed Co', 'VIREMENT');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Avant fix ERP-139 : ces refs sortaient en IRI nu ("/api/tva_modes/30")
|
||||
// car les entites partagees ne portaient que client:/supplier:read:accounting,
|
||||
// pas provider:read:accounting. Apres fix : objet {id, code, label} embarque
|
||||
// (le front consultation/edition affiche le libelle sans fetch — § 4.0.bis).
|
||||
foreach (['tvaMode', 'paymentDelay', 'paymentType', 'bank'] as $ref) {
|
||||
self::assertArrayHasKey($ref, $data, sprintf('Le ref comptable "%s" doit etre present.', $ref));
|
||||
self::assertIsArray($data[$ref], sprintf('Le ref "%s" doit etre un objet embarque, pas un IRI nu.', $ref));
|
||||
self::assertArrayHasKey('id', $data[$ref]);
|
||||
self::assertArrayHasKey('label', $data[$ref]);
|
||||
self::assertNotSame('', (string) $data[$ref]['label']);
|
||||
}
|
||||
|
||||
// paymentType embarque aussi son code (logique front VIREMENT/LCR).
|
||||
self::assertArrayHasKey('code', $data['paymentType']);
|
||||
self::assertSame('VIREMENT', $data['paymentType']['code']);
|
||||
}
|
||||
|
||||
// === #3 — Booleen isArchived present dans le JSON ===
|
||||
|
||||
public function testProviderIsArchivedBooleanIsPresentInDetail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Bool Archived Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// isArchived expose via Groups + SerializedName('isArchived') sur le getter :
|
||||
// sans cela Symfony exposerait la cle "archived" et la droppait (piege n°3 M1).
|
||||
self::assertArrayHasKey('isArchived', $data);
|
||||
self::assertFalse($data['isArchived']);
|
||||
}
|
||||
|
||||
// === #1 — Embed code/name des Category (liste ET detail) ===
|
||||
|
||||
public function testCategoriesEmbedCodeAndNameInDetail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Embed Cat Detail Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertNotEmpty($data['categories']);
|
||||
$category = $data['categories'][0];
|
||||
// Avant correctif : seuls @id/@type (category:read absent du contexte).
|
||||
// Apres : code + name embarques.
|
||||
self::assertArrayHasKey('code', $category);
|
||||
self::assertArrayHasKey('name', $category);
|
||||
self::assertSame('NETTOYAGE', $category['code']);
|
||||
|
||||
// Categories d'adresse aussi (category:read dans le contexte du detail).
|
||||
self::assertArrayHasKey('categories', $data['addresses'][0]);
|
||||
self::assertNotEmpty($data['addresses'][0]['categories']);
|
||||
self::assertArrayHasKey('code', $data['addresses'][0]['categories'][0]);
|
||||
}
|
||||
|
||||
public function testCategoriesEmbedCodeAndNameInList(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$token = 'CatList'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
$provider = $this->seedCompleteProvider($token);
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$list = $http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
$row = $this->memberById($list, (int) $provider->getId());
|
||||
self::assertNotNull($row, 'Le prestataire seede doit apparaitre dans la liste filtree.');
|
||||
self::assertNotEmpty($row['categories']);
|
||||
self::assertArrayHasKey('code', $row['categories'][0]);
|
||||
self::assertArrayHasKey('name', $row['categories'][0]);
|
||||
self::assertSame('NETTOYAGE', $row['categories'][0]['code']);
|
||||
}
|
||||
|
||||
// === #2 — Embed name/postalCode des Site (liste via relation directe + detail) ===
|
||||
|
||||
public function testSitesEmbedNameAndPostalCodeInList(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$token = 'SiteList'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
$provider = $this->seedCompleteProvider($token);
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$list = $http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
$row = $this->memberById($list, (int) $provider->getId());
|
||||
self::assertNotNull($row);
|
||||
// sites en relation DIRECTE provider.sites (RG-3.03) : objet Site entier
|
||||
// (name + postalCode), pas un IRI nu (piege n°2 M1). Multi-sites (>= 2).
|
||||
self::assertArrayHasKey('sites', $row);
|
||||
self::assertGreaterThanOrEqual(2, count($row['sites']));
|
||||
self::assertArrayHasKey('name', $row['sites'][0]);
|
||||
self::assertArrayHasKey('postalCode', $row['sites'][0]);
|
||||
self::assertNotSame('', (string) $row['sites'][0]['name']);
|
||||
}
|
||||
|
||||
public function testSitesEmbedNameAndPostalCodeInDetail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Site Detail Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Sites du formulaire principal (relation directe).
|
||||
self::assertArrayHasKey('sites', $data);
|
||||
self::assertGreaterThanOrEqual(2, count($data['sites']));
|
||||
self::assertArrayHasKey('name', $data['sites'][0]);
|
||||
self::assertArrayHasKey('postalCode', $data['sites'][0]);
|
||||
|
||||
// Sites de l'adresse (addresses[].sites[]).
|
||||
$address = $data['addresses'][0];
|
||||
self::assertArrayHasKey('sites', $address);
|
||||
self::assertGreaterThanOrEqual(2, count($address['sites']), 'L\'adresse seedee est multi-sites.');
|
||||
self::assertArrayHasKey('name', $address['sites'][0]);
|
||||
self::assertArrayHasKey('postalCode', $address['sites'][0]);
|
||||
self::assertNotSame('', (string) $address['sites'][0]['name']);
|
||||
}
|
||||
|
||||
// === Detail : sous-collections embarquees ===
|
||||
|
||||
public function testDetailEmbedsContactsAddressesRibs(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Embed Subres Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertNotEmpty($data['contacts']);
|
||||
self::assertSame('Marie', $data['contacts'][0]['firstName']);
|
||||
self::assertSame('Martin', $data['contacts'][0]['lastName']);
|
||||
self::assertArrayHasKey('email', $data['contacts'][0]);
|
||||
|
||||
self::assertNotEmpty($data['addresses']);
|
||||
// M3 : adresse simplifiee, PAS de addressType.
|
||||
self::assertArrayNotHasKey('addressType', $data['addresses'][0]);
|
||||
self::assertSame('Poitiers', $data['addresses'][0]['city']);
|
||||
|
||||
self::assertNotEmpty($data['ribs']);
|
||||
}
|
||||
|
||||
// === refonte-contact V0.2 : pas de contact inline sur le prestataire ===
|
||||
|
||||
public function testProviderHasNoInlineContactFields(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('No Inline Contact Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Les champs de contact vivent UNIQUEMENT sous contacts[] (refonte-contact).
|
||||
foreach (['firstName', 'lastName', 'phonePrimary', 'phoneSecondary', 'email'] as $key) {
|
||||
self::assertArrayNotHasKey($key, $data, sprintf('Le champ inline "%s" ne doit plus exister au niveau du prestataire.', $key));
|
||||
}
|
||||
}
|
||||
|
||||
// === Enveloppe AP4 (sans prefixe hydra:) + exclusion des archives ===
|
||||
|
||||
public function testCollectionEnvelopeShapeAndArchivedExcluded(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$token = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
|
||||
$this->seedProvider($token.' Active', [self::SITE_86]);
|
||||
$this->seedProvider($token.' Archived', [self::SITE_86], isArchived: true);
|
||||
|
||||
// Liste par defaut filtree sur le token : enveloppe member/totalItems sans
|
||||
// prefixe hydra:, archive EXCLU du totalItems (RG-3.16).
|
||||
$default = $http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayHasKey('member', $default);
|
||||
self::assertArrayHasKey('totalItems', $default);
|
||||
self::assertArrayNotHasKey('hydra:member', $default);
|
||||
self::assertArrayNotHasKey('hydra:totalItems', $default);
|
||||
self::assertSame(1, $default['totalItems'], 'Archive exclu du totalItems par defaut.');
|
||||
|
||||
// includeArchived : l'archive reintegre le total.
|
||||
$all = $http->request('GET', '/api/providers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertSame(2, $all['totalItems']);
|
||||
|
||||
// `view` (PartialCollectionView) sans prefixe hydra:.
|
||||
$paged = $http->request('GET', '/api/providers?search='.$token.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertArrayHasKey('view', $paged);
|
||||
self::assertArrayNotHasKey('hydra:view', $paged);
|
||||
}
|
||||
|
||||
/**
|
||||
* DoD (§ 4.0.bis) : capture des reponses JSON REELLES (liste + detail admin +
|
||||
* detail sans accounting.view) pour les coller dans la spec avant de lancer les
|
||||
* tickets front. Le test asserte la forme ; si la variable d'env
|
||||
* PROVIDER_DOD_DUMP est positionnee, il ecrit aussi les 3 corps formates sous
|
||||
* /tmp pour copie.
|
||||
*/
|
||||
public function testDodReferenceJsonShape(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$token = 'DoD'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
$provider = $this->seedCompleteProvider($token);
|
||||
$id = (int) $provider->getId();
|
||||
|
||||
$admin = $this->createAdminClient();
|
||||
$list = $admin->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
$detailAdmin = $admin->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
$creds = $this->createUserWithPermissions(['technique.providers.view']);
|
||||
$restricted = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
$detailRestricted = $restricted->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Forme minimale attendue (la DoD valide que tout champ front est present).
|
||||
self::assertArrayHasKey('member', $list);
|
||||
self::assertArrayHasKey('siren', $detailAdmin);
|
||||
self::assertArrayHasKey('ribs', $detailAdmin);
|
||||
self::assertArrayNotHasKey('siren', $detailRestricted);
|
||||
self::assertArrayNotHasKey('ribs', $detailRestricted);
|
||||
|
||||
if (false !== getenv('PROVIDER_DOD_DUMP')) {
|
||||
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
|
||||
file_put_contents('/tmp/provider-dod-list.json', json_encode($list, $flags));
|
||||
file_put_contents('/tmp/provider-dod-detail-admin.json', json_encode($detailAdmin, $flags));
|
||||
file_put_contents('/tmp/provider-dod-detail-restricted.json', json_encode($detailRestricted, $flags));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrouve un membre de la collection par son id (liste filtree).
|
||||
*
|
||||
* @param array<string, mixed> $collection
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function memberById(array $collection, int $id): ?array
|
||||
{
|
||||
foreach ($collection['member'] ?? [] as $member) {
|
||||
if (($member['id'] ?? null) === $id) {
|
||||
return $member;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
/**
|
||||
* Tests du cloisonnement par site pilote par l'utilisateur (RG-3.17 / § 2.13) —
|
||||
* ERP-134. Couvre la LECTURE (liste filtree avant pagination + totalItems, detail
|
||||
* 404 hors perimetre, bypass voit tout) et l'ECRITURE (sites hors user_site -> 422).
|
||||
*
|
||||
* Cloisonnement pilote par l'USER (pas le role) : on cree des users non-admin SANS
|
||||
* `sites.bypass_scope`, rattaches a un site precis avec un currentSite. L'admin
|
||||
* (isAdmin -> bypass total) sert de temoin « voit tout ».
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderSiteScopeTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// Pre-requis : le module Sites doit etre actif (sinon currentSite = null,
|
||||
// cloisonnement no-op et ces tests perdent leur sens).
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
}
|
||||
|
||||
public function testListIsScopedToCurrentSiteForNonBypassUser(): void
|
||||
{
|
||||
$this->seedProvider('Presta Site 86', [self::SITE_86]);
|
||||
$this->seedProvider('Presta Site 17', [self::SITE_17]);
|
||||
$this->seedProvider('Presta Site 82', [self::SITE_82]);
|
||||
|
||||
$creds = $this->createScopedUser(
|
||||
['technique.providers.view'],
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$response = $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$body = $response->toArray();
|
||||
// totalItems reflete le PERIMETRE de l'user (filtre avant pagination).
|
||||
self::assertSame(1, $body['totalItems']);
|
||||
self::assertSame('PRESTA SITE 86', $body['member'][0]['companyName']);
|
||||
}
|
||||
|
||||
public function testDetailOutOfScopeReturns404(): void
|
||||
{
|
||||
$inScope = $this->seedProvider('Dans Perimetre', [self::SITE_86]);
|
||||
$outOfScope = $this->seedProvider('Hors Perimetre', [self::SITE_17]);
|
||||
|
||||
$creds = $this->createScopedUser(
|
||||
['technique.providers.view'],
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
// In-scope -> 200.
|
||||
$ok = $client->request('GET', '/api/providers/'.$inScope->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(200, $ok->getStatusCode());
|
||||
|
||||
// Out-of-scope -> 404 (ne pas reveler l'existence hors perimetre).
|
||||
$ko = $client->request('GET', '/api/providers/'.$outOfScope->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(404, $ko->getStatusCode());
|
||||
}
|
||||
|
||||
public function testBypassUserSeesAllSites(): void
|
||||
{
|
||||
$this->seedProvider('Presta Site 86', [self::SITE_86]);
|
||||
$this->seedProvider('Presta Site 17', [self::SITE_17]);
|
||||
$this->seedProvider('Presta Site 82', [self::SITE_82]);
|
||||
|
||||
// Admin = bypass total.
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame(3, $response->toArray()['totalItems']);
|
||||
}
|
||||
|
||||
public function testWriteOutOfScopeSiteRejectedAtIriResolution(): void
|
||||
{
|
||||
// User non-bypass / non-read_ref : la resolution de l'IRI du site hors
|
||||
// perimetre echoue en amont (SiteCollectionScopedExtension : item Site
|
||||
// « introuvable ») -> 400 anti-enumeration, avant le ProviderProcessor.
|
||||
$creds = $this->createScopedUser(
|
||||
['technique.providers.view', 'technique.providers.manage'],
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$response = $client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Hors Scope Sas', [self::SITE_17]),
|
||||
]);
|
||||
|
||||
self::assertSame(400, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testWriteOutOfScopeSiteRejectedByProcessorGuard(): void
|
||||
{
|
||||
// User `sites.read_ref` : peut RESOUDRE n'importe quel site (referentiel
|
||||
// transverse) mais n'opere que sur ses user_site. La garde guardSiteScope
|
||||
// du ProviderProcessor est alors l'enforcement autoritaire de RG-3.17
|
||||
// -> 422 sur `sites` (mappable inline, ERP-101).
|
||||
$creds = $this->createScopedUser(
|
||||
['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'],
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$response = $client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Hors Scope Guard', [self::SITE_17]),
|
||||
]);
|
||||
|
||||
self::assertSame(422, $response->getStatusCode());
|
||||
self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
public function testWriteAllowsSiteWithinUserScope(): void
|
||||
{
|
||||
$creds = $this->createScopedUser(
|
||||
['technique.providers.view', 'technique.providers.manage'],
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
// Site 86 = un des user_site -> 201.
|
||||
$response = $client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Dans Scope Sas', [self::SITE_86]),
|
||||
]);
|
||||
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testPatchAddingOutOfScopeSiteIsRejected(): void
|
||||
{
|
||||
$provider = $this->seedProvider('Patch Sites', [self::SITE_86]);
|
||||
$id = $provider->getId();
|
||||
|
||||
// read_ref pour pouvoir resoudre l'IRI du site 17 (sinon 400 en amont) et
|
||||
// exercer la garde guardSiteScope sur le PATCH.
|
||||
$creds = $this->createScopedUser(
|
||||
['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'],
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$site86 = $this->site(self::SITE_86)->getId();
|
||||
$site17 = $this->site(self::SITE_17)->getId();
|
||||
|
||||
$response = $client->request('PATCH', '/api/providers/'.$id, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['sites' => ['/api/sites/'.$site86, '/api/sites/'.$site17]],
|
||||
]);
|
||||
|
||||
// RG-3.17 : ajouter un site hors user_site -> 422 (garde Processor).
|
||||
self::assertSame(422, $response->getStatusCode());
|
||||
self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire
|
||||
* (M3, spec § 4.5 — ERP-135). Couvrent : normalisation contact (RG-3.11), RG-3.04
|
||||
* (au moins le prenom OU le nom — aligne M1/M2), RG-3.05 (>= 1 site sur
|
||||
* l'adresse), RG-3.06 (code postal), RG-3.09 (categorie PRESTATAIRE sur adresse),
|
||||
* le cloisonnement d'ecriture des sites de l'adresse (§ 2.13 -> 422 sur `sites`),
|
||||
* RG-3.08 (DELETE dernier RIB sous LCR -> 409), DELETE contact libre au M3 (pas de
|
||||
* garde « dernier contact ») et le gating selon permission (Contacts/Adresses =
|
||||
* manage, RIB = accounting.manage). Jumeau de SupplierSubResourceApiTest.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// seedProvider exige >= 1 site (RG-3.03) : le module Sites doit etre actif.
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
}
|
||||
|
||||
// === Contacts (security: technique.providers.manage) ===
|
||||
|
||||
public function testPostContactNormalizesFields(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Contact Host');
|
||||
|
||||
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'firstName' => 'JEAN',
|
||||
'lastName' => 'dupont',
|
||||
'phonePrimary' => '06.12.34.56.78',
|
||||
'email' => 'Jean.DUPONT@ACME.FR',
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
// RG-3.11 : prenom/nom Title Case, telephone chiffres seuls, email lowercase.
|
||||
self::assertSame('Jean', $data['firstName']);
|
||||
self::assertSame('Dupont', $data['lastName']);
|
||||
self::assertSame('0612345678', $data['phonePrimary']);
|
||||
self::assertSame('jean.dupont@acme.fr', $data['email']);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.04 (aligne M1/M2) : un bloc Contact exige le prenom OU le nom. Une
|
||||
* Fonction seule (sans nom ni prenom) ne suffit plus -> 422 rattachee a firstName.
|
||||
*/
|
||||
public function testPostContactWithOnlyJobTitleReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Contact JobTitle Only');
|
||||
|
||||
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['jobTitle' => 'Directeur'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.04 : un bloc Contact sans prenom NI nom (meme avec d'autres champs ou
|
||||
* apres normalisation des chaines vides) est rejete avant la base -> 422
|
||||
* rattachee a firstName (double garde CHECK chk_provider_contact_name).
|
||||
*/
|
||||
public function testPostContactWithoutNameReturns422OnFirstNamePath(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Contact No Name');
|
||||
|
||||
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
// Email + telephone fournis mais ni prenom ni nom -> invalide (RG-3.04).
|
||||
'json' => ['email' => 'contact@acme.fr', 'phonePrimary' => '0612345678'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.04 : le prenom SEUL (sans nom) suffit a valider le contact -> 201.
|
||||
*/
|
||||
public function testPostContactWithOnlyFirstNameReturns201(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Contact FirstName Only');
|
||||
|
||||
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['firstName' => 'Jean'],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame('Jean', $data['firstName']);
|
||||
}
|
||||
|
||||
public function testPostContactOnMissingProviderReturns404(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/providers/999999/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['firstName' => 'Orphan'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testPatchContactNormalizesFields(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Contact Patch');
|
||||
$contact = $this->addContact($seed, 'Marie', 'Martin');
|
||||
|
||||
$data = $client->request('PATCH', '/api/provider_contacts/'.$contact->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['lastName' => 'durand'],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
// Normalisation aussi sur PATCH : "durand" -> "Durand".
|
||||
self::assertSame('Durand', $data['lastName']);
|
||||
}
|
||||
|
||||
public function testDeleteLastContactReturns204(): void
|
||||
{
|
||||
// M3 : pas de garde « dernier contact » (RG-3.12 front-driven) — la
|
||||
// suppression du dernier contact est libre (204).
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Contact Solo');
|
||||
$contact = $this->addContact($seed, 'Unique', 'Contact');
|
||||
|
||||
$client->request('DELETE', '/api/provider_contacts/'.$contact->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
public function testContactWriteWithoutManageReturns403(): void
|
||||
{
|
||||
// Un user sans permission technique.providers.manage -> 403 sur la sous-ressource.
|
||||
$seed = $this->seedProvider('Contact Forbidden');
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$http->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['firstName' => 'Nope'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// === Adresses (security: technique.providers.manage) ===
|
||||
|
||||
public function testPostAddressWithValidPayloadReturns201(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Address Host');
|
||||
$category = $this->providerCategory('NETTOYAGE');
|
||||
|
||||
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame('Châtellerault', $data['city']);
|
||||
}
|
||||
|
||||
public function testPostAddressWithoutSiteReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Address No Site');
|
||||
|
||||
$client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [],
|
||||
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-3.05 (Assert\Count min 1 sur sites).
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testPostAddressWithInvalidPostalCodeReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Address Bad CP');
|
||||
|
||||
$client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '123',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
|
||||
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-3.06 (Assert\Regex ^[0-9]{4,5}$).
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testPostAddressWithNonPrestataireCategoryReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Address Bad Cat');
|
||||
$foreign = $this->foreignCategory(); // type CLIENT -> interdite (RG-3.09).
|
||||
|
||||
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
|
||||
'categories' => ['/api/categories/'.$foreign->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-3.09 -> 422 rattachee a categories.
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
public function testDeleteAddressReturns204(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Address Delete');
|
||||
$category = $this->providerCategory('NETTOYAGE');
|
||||
|
||||
$created = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
$client->request('DELETE', $created['@id']);
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
public function testAddressWriteWithoutManageReturns403(): void
|
||||
{
|
||||
$seed = $this->seedProvider('Address Forbidden');
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$http->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* § 2.13 / RG-3.05 (cloisonnement d'ECRITURE sur l'adresse) : un user non-bypass
|
||||
* `sites.read_ref` (qui peut resoudre n'importe quel IRI de site, sinon 400 en
|
||||
* amont) ne peut attacher a l'adresse que ses propres user_site. Site hors
|
||||
* perimetre -> 422 sur `sites` (garde ProviderAddressProcessor).
|
||||
*/
|
||||
public function testPostAddressWithOutOfScopeSiteReturns422OnSitesPath(): void
|
||||
{
|
||||
$seed = $this->seedProvider('Address Scope', [self::SITE_86]);
|
||||
$category = $this->providerCategory('NETTOYAGE');
|
||||
|
||||
$creds = $this->createScopedUser(
|
||||
['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'],
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '17400',
|
||||
'city' => 'Saint-Jean-d\'Angély',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => ['/api/sites/'.$this->site(self::SITE_17)->getId()], // hors user_site
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
// === RIBs (security: technique.providers.accounting.manage) ===
|
||||
|
||||
public function testPostRibByAdminReturns201(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Rib Host');
|
||||
|
||||
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'label' => 'Compte principal',
|
||||
'bic' => self::VALID_BIC,
|
||||
'iban' => self::VALID_IBAN,
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame('Compte principal', $data['label']);
|
||||
}
|
||||
|
||||
public function testPostRibWithInvalidIbanReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Rib Bad Iban');
|
||||
|
||||
$client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['label' => 'Compte invalide', 'bic' => self::VALID_BIC, 'iban' => 'INVALID-IBAN'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* Controle croise pays BIC/IBAN (Assert\Bic ibanPropertyPath) : un BIC (DE) et
|
||||
* un IBAN (FR) valides isolement mais de pays differents -> 422 sur `bic`.
|
||||
*/
|
||||
public function testPostRibWithBicIbanCountryMismatchReturns422OnBic(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Rib Pays Mismatch');
|
||||
|
||||
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['label' => 'Compte incoherent', 'bic' => self::FOREIGN_BIC, 'iban' => self::VALID_IBAN],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
$byPath = $this->violationsByPath($response->toArray(false));
|
||||
self::assertArrayHasKey('bic', $byPath);
|
||||
self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']);
|
||||
}
|
||||
|
||||
public function testDeleteRibNonLcrReturns204(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Rib Non LCR');
|
||||
$rib = $this->addRib($seed);
|
||||
|
||||
$client->request('DELETE', '/api/provider_ribs/'.$rib->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
public function testDeleteLastRibUnderLcrReturns409(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Rib LCR Solo');
|
||||
$rib = $this->addRib($seed);
|
||||
|
||||
// Passe le prestataire en LCR (seed direct).
|
||||
$em = $this->getEm();
|
||||
$managed = $em->getRepository(Provider::class)->find($seed->getId());
|
||||
$managed->setPaymentType($this->paymentType('LCR'));
|
||||
$em->flush();
|
||||
|
||||
$client->request('DELETE', '/api/provider_ribs/'.$rib->getId());
|
||||
|
||||
// RG-3.08 : LCR exige >= 1 RIB -> suppression du dernier refusee.
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
}
|
||||
|
||||
public function testRibWriteWithoutAccountingManageReturns403(): void
|
||||
{
|
||||
// Un user portant seulement technique.providers.manage (sans accounting.manage)
|
||||
// ne peut ni creer, ni modifier, ni supprimer un RIB (gating renforce § 4.5).
|
||||
$seed = $this->seedProvider('Rib Forbidden');
|
||||
$rib = $this->addRib($seed);
|
||||
$creds = $this->createUserWithPermission('technique.providers.manage');
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$http->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['label' => 'X', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$http->request('PATCH', '/api/provider_ribs/'.$rib->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['label' => 'Y'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$http->request('DELETE', '/api/provider_ribs/'.$rib->getId());
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
|
||||
/**
|
||||
* Tests du cloisonnement par site des SOUS-RESSOURCES d'un prestataire (Contacts /
|
||||
* Adresses / RIB) — § 2.13 / RG-3.17. Complement de ProviderSiteScopeTest (qui ne
|
||||
* couvrait que le Provider lui-meme).
|
||||
*
|
||||
* Sans garde dedie, un user cloisonne pouvait lire / editer / supprimer une
|
||||
* sous-ressource d'un prestataire HORS de son site (le detail Provider est garde en
|
||||
* 404, mais les sous-ressources passent par le provider Doctrine par defaut, non
|
||||
* cloisonne — et SiteScopedQueryExtension ne filtre que les SiteAwareInterface).
|
||||
* Le RIB est particulierement sensible (IBAN / BIC).
|
||||
*
|
||||
* Garde pose par ProviderSubResourceItemProvider (Get/Patch/Delete -> 404 hors
|
||||
* perimetre) + ProviderSiteScopeChecker::assertInScope dans les processors (POST
|
||||
* sur parent hors perimetre -> 404). Decision de scope partagee (source unique).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderSubResourceSiteScopeTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
/** Permissions completes pour exercer view + manage + accounting sur tous les chemins. */
|
||||
private const array FULL_PERMS = [
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
}
|
||||
|
||||
public function testGetContactOutOfScopeReturns404ButInScope200(): void
|
||||
{
|
||||
$inScope = $this->seedProvider('Presta In Scope', [self::SITE_86]);
|
||||
$inContactId = $this->addContact($inScope, 'Marie', 'Martin')->getId();
|
||||
|
||||
$outScope = $this->seedProvider('Presta Out Scope', [self::SITE_17]);
|
||||
$outContactId = $this->addContact($outScope, 'Paul', 'Durand')->getId();
|
||||
|
||||
$client = $this->scopedClient();
|
||||
|
||||
$ok = $client->request('GET', '/api/provider_contacts/'.$inContactId, ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(200, $ok->getStatusCode());
|
||||
|
||||
// Hors perimetre : 404 (ne pas reveler l'existence du contact d'un autre site).
|
||||
$ko = $client->request('GET', '/api/provider_contacts/'.$outContactId, ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(404, $ko->getStatusCode());
|
||||
}
|
||||
|
||||
public function testGetRibOutOfScopeReturns404(): void
|
||||
{
|
||||
// RIB = donnee bancaire sensible (IBAN/BIC) : le cas le plus critique.
|
||||
$outScope = $this->seedProvider('Presta Out Rib', [self::SITE_17]);
|
||||
$ribId = $this->addRib($outScope)->getId();
|
||||
|
||||
$client = $this->scopedClient();
|
||||
|
||||
$response = $client->request('GET', '/api/provider_ribs/'.$ribId, ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testPatchRibOutOfScopeReturns404(): void
|
||||
{
|
||||
$outScope = $this->seedProvider('Presta Patch Rib', [self::SITE_17]);
|
||||
$ribId = $this->addRib($outScope)->getId();
|
||||
|
||||
$client = $this->scopedClient();
|
||||
|
||||
$response = $client->request('PATCH', '/api/provider_ribs/'.$ribId, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['label' => 'Hacked'],
|
||||
]);
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testDeleteContactOutOfScopeReturns404(): void
|
||||
{
|
||||
$outScope = $this->seedProvider('Presta Del Contact', [self::SITE_17]);
|
||||
$contactId = $this->addContact($outScope, 'Paul', 'Durand')->getId();
|
||||
|
||||
$client = $this->scopedClient();
|
||||
|
||||
$response = $client->request('DELETE', '/api/provider_contacts/'.$contactId);
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testPostContactOnOutOfScopeProviderReturns404(): void
|
||||
{
|
||||
$outScope = $this->seedProvider('Presta Post Contact', [self::SITE_17]);
|
||||
$id = $outScope->getId();
|
||||
|
||||
$client = $this->scopedClient();
|
||||
|
||||
$response = $client->request('POST', '/api/providers/'.$id.'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['firstName' => 'Intrus'],
|
||||
]);
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testPostRibOnOutOfScopeProviderReturns404(): void
|
||||
{
|
||||
$outScope = $this->seedProvider('Presta Post Rib', [self::SITE_17]);
|
||||
$id = $outScope->getId();
|
||||
|
||||
$client = $this->scopedClient();
|
||||
|
||||
$response = $client->request('POST', '/api/providers/'.$id.'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => [
|
||||
'label' => 'Intrus',
|
||||
'iban' => self::VALID_IBAN,
|
||||
'bic' => self::VALID_BIC,
|
||||
],
|
||||
]);
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testBypassUserReachesSubResourceOnAnySite(): void
|
||||
{
|
||||
// Temoin : l'admin (bypass total) lit bien un contact hors « son » site.
|
||||
$outScope = $this->seedProvider('Presta Admin Reach', [self::SITE_17]);
|
||||
$contactId = $this->addContact($outScope, 'Marie', 'Martin')->getId();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/provider_contacts/'.$contactId, ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Client authentifie comme un user NON-bypass rattache au seul site 86 (avec
|
||||
* currentSite 86) — sujet des tests de cloisonnement des sous-ressources.
|
||||
*/
|
||||
private function scopedClient(): Client
|
||||
{
|
||||
$creds = $this->createScopedUser(
|
||||
self::FULL_PERMS,
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
|
||||
return $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Application\Idtf;
|
||||
|
||||
use App\Module\Transport\Application\Idtf\IdtfSheetParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class IdtfSheetParserTest extends TestCase
|
||||
{
|
||||
public function testExtractsExportDate(): void
|
||||
{
|
||||
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
|
||||
self::assertSame('2026-06-12', $parsed['exportDate']);
|
||||
}
|
||||
|
||||
public function testParsesAndNormalizesFirstRow(): void
|
||||
{
|
||||
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
|
||||
$row = $parsed['rows'][0];
|
||||
|
||||
self::assertSame(30748, $row['idtf_number']);
|
||||
self::assertSame('Argiles avec régime de nettoyage C', $row['name']);
|
||||
self::assertSame('C', $row['cleaning_regime']);
|
||||
self::assertSame('2026-04-02', $row['mandatory_date']);
|
||||
self::assertSame('Al2O3', $row['formula']);
|
||||
self::assertSame('01 01 01', $row['eural_code']);
|
||||
self::assertSame(['7631-86-9', '1344-28-1'], $row['cas_numbers']);
|
||||
self::assertSame('Note 1', $row['footnotes']);
|
||||
}
|
||||
|
||||
public function testSkipsEmptyAndNonNumericRows(): void
|
||||
{
|
||||
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
|
||||
|
||||
// 2 lignes exploitables (30748 et 30744) ; vide + "abc" ignorees.
|
||||
self::assertCount(2, $parsed['rows']);
|
||||
self::assertSame(30744, $parsed['rows'][1]['idtf_number']);
|
||||
}
|
||||
|
||||
public function testEmptyOptionalCellsBecomeNullAndCasEmpty(): void
|
||||
{
|
||||
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
|
||||
$row = $parsed['rows'][1]; // 30744
|
||||
|
||||
self::assertNull($row['mandatory_date']);
|
||||
self::assertNull($row['formula']);
|
||||
self::assertNull($row['product_group']);
|
||||
self::assertSame([], $row['cas_numbers']);
|
||||
}
|
||||
|
||||
public function testColumnOrderIsResolvedByLabel(): void
|
||||
{
|
||||
// En-tete dans un ordre different : le mapping doit suivre les libelles.
|
||||
$matrix = [
|
||||
['Export date: 1-1-2026'],
|
||||
['Numéro CAS', 'Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'],
|
||||
['7440-44-0', '99', 'Carbone', 'B'],
|
||||
];
|
||||
|
||||
$parsed = IdtfSheetParser::parse($matrix);
|
||||
$row = $parsed['rows'][0];
|
||||
|
||||
self::assertSame(99, $row['idtf_number']);
|
||||
self::assertSame('Carbone', $row['name']);
|
||||
self::assertSame('B', $row['cleaning_regime']);
|
||||
self::assertSame(['7440-44-0'], $row['cas_numbers']);
|
||||
}
|
||||
|
||||
public function testThrowsWhenHeaderMissing(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
IdtfSheetParser::parse([['foo', 'bar'], ['1', '2']]);
|
||||
}
|
||||
|
||||
public function testExportDateNullWhenAbsent(): void
|
||||
{
|
||||
$matrix = [
|
||||
['Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'],
|
||||
['1', 'X', 'A'],
|
||||
];
|
||||
|
||||
self::assertNull(IdtfSheetParser::parse($matrix)['exportDate']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matrice representative de l'export reel : preambule (lignes 0-1), ligne
|
||||
* vide (2), en-tete (3) puis donnees.
|
||||
*
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
private function sampleMatrix(): array
|
||||
{
|
||||
return [
|
||||
['Export date: 12-6-2026'],
|
||||
['Changes in the database after this date...'],
|
||||
[],
|
||||
['Numéro IDTF', 'Product Group', 'Nom de la marchandise', 'Régime de nettoyage', 'Exigences importantes', 'Date d’application obligatoire', 'Produits apparentés', 'Formule', 'Code EURAL', 'Numéro CAS', 'Annotations'],
|
||||
['30748', 'Substances inorganiques', 'Argiles avec régime de nettoyage C', 'C', 'Exigence X', '02-04-2026', 'Poudre argile', 'Al2O3', '01 01 01', '7631-86-9 ; 1344-28-1', 'Note 1'],
|
||||
['', '', '', '', '', '', '', '', '', '', ''],
|
||||
['abc', 'ligne non numerique a ignorer', '', '', '', '', '', '', '', '', ''],
|
||||
['30744', '', 'Additifs alimentaires', 'A', '', '', '', '', '', '', ''],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Application\Qualimat;
|
||||
|
||||
use App\Module\Transport\Application\Qualimat\QualimatRowMapper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class QualimatRowMapperTest extends TestCase
|
||||
{
|
||||
public function testNormalizeSiretStripsNonDigits(): void
|
||||
{
|
||||
self::assertSame('44415628500025', QualimatRowMapper::normalizeSiret('444 156 285 000 25'));
|
||||
self::assertNull(QualimatRowMapper::normalizeSiret(null));
|
||||
self::assertNull(QualimatRowMapper::normalizeSiret(' '));
|
||||
self::assertNull(QualimatRowMapper::normalizeSiret(''));
|
||||
}
|
||||
|
||||
public function testParseDate(): void
|
||||
{
|
||||
self::assertSame('2027-05-14', QualimatRowMapper::parseDate('14/05/2027'));
|
||||
self::assertNull(QualimatRowMapper::parseDate(null));
|
||||
self::assertNull(QualimatRowMapper::parseDate('2027-05-14'));
|
||||
self::assertNull(QualimatRowMapper::parseDate('14-05-2027'));
|
||||
// Date calendaire impossible : evite un INSERT en erreur.
|
||||
self::assertNull(QualimatRowMapper::parseDate('31/02/2027'));
|
||||
}
|
||||
|
||||
public function testMapOneNormalizesAndTrims(): void
|
||||
{
|
||||
$row = QualimatRowMapper::mapOne([
|
||||
'Nom' => ' 2C TRANS ',
|
||||
'Societe' => '2C TRANS',
|
||||
'Adresse' => '66 Impasse Mendi',
|
||||
'CodePostal' => '65500',
|
||||
'Ville' => 'VIC EN BIGORRE',
|
||||
'Telephone_1' => '+33|0608890316',
|
||||
'Siret' => '444 156 285 000 25',
|
||||
'Validite' => '14/05/2027',
|
||||
'Statut' => 'Audité',
|
||||
'Departement' => '65 - Hautes-Pyrénées',
|
||||
]);
|
||||
|
||||
self::assertNotNull($row);
|
||||
self::assertSame('44415628500025', $row['siret']);
|
||||
self::assertSame('2C TRANS', $row['name']);
|
||||
self::assertSame('2027-05-14', $row['validity_date']);
|
||||
self::assertSame('+33|0608890316', $row['phone']);
|
||||
self::assertSame('Audité', $row['status']);
|
||||
self::assertSame('65 - Hautes-Pyrénées', $row['department']);
|
||||
}
|
||||
|
||||
public function testMapOneReturnsNullWithoutSiret(): void
|
||||
{
|
||||
self::assertNull(QualimatRowMapper::mapOne(['Nom' => 'X', 'Siret' => null]));
|
||||
self::assertNull(QualimatRowMapper::mapOne(['Nom' => 'X']));
|
||||
self::assertNull(QualimatRowMapper::mapOne(['Nom' => 'X', 'Siret' => ' ']));
|
||||
}
|
||||
|
||||
public function testMapManyCountsSkipped(): void
|
||||
{
|
||||
$result = QualimatRowMapper::mapMany([
|
||||
['Nom' => 'A', 'Siret' => '111 111 111 00011', 'Statut' => 'Audité', 'Validite' => '01/01/2030'],
|
||||
['Nom' => 'B', 'Siret' => null],
|
||||
['Nom' => 'C', 'Siret' => ' '],
|
||||
]);
|
||||
|
||||
self::assertCount(1, $result['rows']);
|
||||
self::assertSame(2, $result['skipped']);
|
||||
}
|
||||
|
||||
public function testMapManyDeduplicatesBySiretLastWins(): void
|
||||
{
|
||||
// Memes chiffres a separateurs pres : un seul transporteur, derniere
|
||||
// occurrence gagnante (le compte ne doit pas surcompter les doublons).
|
||||
$result = QualimatRowMapper::mapMany([
|
||||
['Nom' => 'PREMIER', 'Siret' => '111 111 111 00011', 'Statut' => 'Audité'],
|
||||
['Nom' => 'DERNIER', 'Siret' => '11111111100011', 'Statut' => 'Valide'],
|
||||
]);
|
||||
|
||||
self::assertCount(1, $result['rows']);
|
||||
self::assertSame(0, $result['skipped']);
|
||||
self::assertSame('DERNIER', $result['rows'][0]['name']);
|
||||
self::assertSame('Valide', $result['rows'][0]['status']);
|
||||
}
|
||||
|
||||
public function testEmptyOptionalFieldsBecomeNull(): void
|
||||
{
|
||||
$row = QualimatRowMapper::mapOne([
|
||||
'Siret' => '111 111 111 00011',
|
||||
'Nom' => 'A',
|
||||
'Adresse' => '',
|
||||
'Ville' => ' ',
|
||||
]);
|
||||
|
||||
self::assertNotNull($row);
|
||||
self::assertNull($row['address']);
|
||||
self::assertNull($row['city']);
|
||||
self::assertNull($row['validity_date']);
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Infrastructure\Console;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
/**
|
||||
* Test fonctionnel de `app:idtf:sync` via --file : genere un vrai .xlsx, le
|
||||
* passe a la commande et verifie le parsing, l'upsert, le journal et le
|
||||
* soft-delete (chemin complet IOFactory -> parser -> DBAL).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SyncIdtfCommandTest extends KernelTestCase
|
||||
{
|
||||
private Connection $connection;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
/** @var Connection $connection */
|
||||
$connection = self::getContainer()->get('doctrine.dbal.default_connection');
|
||||
$this->connection = $connection;
|
||||
$this->purge();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->purge();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testSyncParsesXlsxUpsertsAndLogs(): void
|
||||
{
|
||||
$path = $this->makeXlsx([
|
||||
['Export date: 12-6-2026'],
|
||||
['Avertissement preambule'],
|
||||
[],
|
||||
['Numéro IDTF', 'Product Group', 'Nom de la marchandise', 'Régime de nettoyage', 'Exigences importantes', 'Date d’application obligatoire', 'Produits apparentés', 'Formule', 'Code EURAL', 'Numéro CAS', 'Annotations'],
|
||||
['30748', 'Inorganiques', 'Argiles régime C', 'C', 'Exig X', '02-04-2026', 'Poudre', 'Al2O3', '01 01 01', '7631-86-9 ; 1344-28-1', 'Note'],
|
||||
['', '', '', '', '', '', '', '', '', '', ''],
|
||||
['30744', '', 'Additifs', 'A', '', '', '', '', '', '', ''],
|
||||
]);
|
||||
|
||||
$tester = $this->runSync($path);
|
||||
$tester->assertCommandIsSuccessful();
|
||||
|
||||
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM idtf_product'));
|
||||
|
||||
$row = $this->connection->fetchAssociative("SELECT * FROM idtf_product WHERE idtf_number = 30748 AND schema = 'road'");
|
||||
self::assertNotFalse($row);
|
||||
self::assertSame('Argiles régime C', $row['name']);
|
||||
self::assertSame('C', $row['cleaning_regime']);
|
||||
self::assertSame('2026-04-02', $row['mandatory_date']);
|
||||
self::assertSame('2026-06-12', $row['source_export_date']);
|
||||
self::assertSame(['7631-86-9', '1344-28-1'], json_decode((string) $row['cas_numbers'], true));
|
||||
|
||||
$log = $this->connection->fetchAssociative('SELECT * FROM idtf_sync_log ORDER BY id DESC LIMIT 1');
|
||||
self::assertNotFalse($log);
|
||||
self::assertSame('road', $log['schema']);
|
||||
self::assertSame('2026-06-12', $log['export_date']);
|
||||
self::assertSame(2, (int) $log['rows_total']);
|
||||
self::assertSame(2, (int) $log['rows_upserted']);
|
||||
self::assertSame(0, (int) $log['rows_deactivated']);
|
||||
}
|
||||
|
||||
public function testSecondSyncSoftDeletesMissing(): void
|
||||
{
|
||||
$header = ['Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'];
|
||||
|
||||
$this->runSync($this->makeXlsx([
|
||||
['Export date: 1-6-2026'],
|
||||
$header,
|
||||
['100', 'Produit 100', 'A'],
|
||||
['200', 'Produit 200', 'B'],
|
||||
]))->assertCommandIsSuccessful();
|
||||
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM idtf_product WHERE is_active = TRUE'));
|
||||
|
||||
// 2e export sans 200 -> soft-delete de 200, mise a jour de 100.
|
||||
$tester = $this->runSync($this->makeXlsx([
|
||||
['Export date: 2-6-2026'],
|
||||
$header,
|
||||
['100', 'Produit 100 maj', 'C'],
|
||||
]));
|
||||
$tester->assertCommandIsSuccessful();
|
||||
|
||||
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM idtf_product'));
|
||||
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM idtf_product WHERE is_active = TRUE'));
|
||||
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM idtf_product WHERE idtf_number = 200 AND is_active = FALSE'));
|
||||
|
||||
$row100 = $this->connection->fetchAssociative('SELECT * FROM idtf_product WHERE idtf_number = 100');
|
||||
self::assertNotFalse($row100);
|
||||
self::assertSame('Produit 100 maj', $row100['name']);
|
||||
self::assertSame('C', $row100['cleaning_regime']);
|
||||
|
||||
$log = $this->connection->fetchAssociative('SELECT * FROM idtf_sync_log ORDER BY id DESC LIMIT 1');
|
||||
self::assertNotFalse($log);
|
||||
self::assertSame(1, (int) $log['rows_upserted']);
|
||||
self::assertSame(1, (int) $log['rows_deactivated']);
|
||||
}
|
||||
|
||||
public function testInvalidSchemaIsRejected(): void
|
||||
{
|
||||
$path = $this->makeXlsx([
|
||||
['Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'],
|
||||
['1', 'X', 'A'],
|
||||
]);
|
||||
|
||||
$application = new Application(self::$kernel);
|
||||
$tester = new CommandTester($application->find('app:idtf:sync'));
|
||||
$exitCode = $tester->execute(['--file' => $path, '--schema' => 'air']);
|
||||
|
||||
@unlink($path);
|
||||
|
||||
self::assertSame(2, $exitCode); // Command::INVALID
|
||||
self::assertSame(0, $this->countRows('SELECT COUNT(*) FROM idtf_product'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<int, mixed>> $matrix
|
||||
*/
|
||||
private function makeXlsx(array $matrix): string
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$spreadsheet->getActiveSheet()->fromArray($matrix, null, 'A1', true);
|
||||
|
||||
$path = tempnam(sys_get_temp_dir(), 'idtf_').'.xlsx';
|
||||
new Xlsx($spreadsheet)->save($path);
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function runSync(string $path): CommandTester
|
||||
{
|
||||
$application = new Application(self::$kernel);
|
||||
$tester = new CommandTester($application->find('app:idtf:sync'));
|
||||
$tester->execute(['--file' => $path, '--schema' => 'road']);
|
||||
|
||||
@unlink($path);
|
||||
|
||||
return $tester;
|
||||
}
|
||||
|
||||
private function countRows(string $sql): int
|
||||
{
|
||||
return (int) $this->connection->fetchOne($sql);
|
||||
}
|
||||
|
||||
private function purge(): void
|
||||
{
|
||||
$this->connection->executeStatement('DELETE FROM idtf_product');
|
||||
$this->connection->executeStatement('DELETE FROM idtf_sync_log');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user