Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f8ed075b6 | |||
| 1e783bd753 | |||
| 9f4f45f761 | |||
| e99747ac72 | |||
| 36edd11854 | |||
| 45cb5c834c | |||
| 2689b85ebe | |||
| f4bbc79550 | |||
| f057866e75 | |||
| 19fdb50cec | |||
| 368bb50ffb | |||
| 6a83adc00a | |||
| c76c447aa2 | |||
| 19ac8833eb | |||
| c25c33116d | |||
| 17aa61d014 | |||
| 3d4ae391fe | |||
| 04c794addb | |||
| c1e45cd582 | |||
| a6f01400ba | |||
| d0e9f48983 |
+2
-2
@@ -24,6 +24,7 @@
|
|||||||
"symfony/expression-language": "8.0.*",
|
"symfony/expression-language": "8.0.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "8.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
|
"symfony/http-client": "8.0.*",
|
||||||
"symfony/intl": "8.0.*",
|
"symfony/intl": "8.0.*",
|
||||||
"symfony/mime": "8.0.*",
|
"symfony/mime": "8.0.*",
|
||||||
"symfony/monolog-bundle": "^4.0",
|
"symfony/monolog-bundle": "^4.0",
|
||||||
@@ -95,7 +96,6 @@
|
|||||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||||
"friendsofphp/php-cs-fixer": "^3.94",
|
"friendsofphp/php-cs-fixer": "^3.94",
|
||||||
"phpunit/phpunit": "^13.0",
|
"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",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "2dc5db01e7f5d6aecd5956749b21a092",
|
"content-hash": "b029c1484227c926d39dfd3ae5cb0699",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -5412,6 +5412,180 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-30T15:14:47+00:00"
|
"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",
|
"name": "symfony/http-foundation",
|
||||||
"version": "v8.0.8",
|
"version": "v8.0.8",
|
||||||
@@ -11785,180 +11959,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-30T15:14:47+00:00"
|
"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",
|
"name": "symfony/process",
|
||||||
"version": "v8.0.8",
|
"version": "v8.0.8",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Module\Commercial\CommercialModule;
|
|||||||
use App\Module\Core\CoreModule;
|
use App\Module\Core\CoreModule;
|
||||||
use App\Module\Sites\SitesModule;
|
use App\Module\Sites\SitesModule;
|
||||||
use App\Module\Technique\TechniqueModule;
|
use App\Module\Technique\TechniqueModule;
|
||||||
|
use App\Module\Transport\TransportModule;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
CoreModule::class,
|
CoreModule::class,
|
||||||
@@ -13,4 +14,5 @@ return [
|
|||||||
SitesModule::class,
|
SitesModule::class,
|
||||||
CatalogModule::class,
|
CatalogModule::class,
|
||||||
TechniqueModule::class,
|
TechniqueModule::class,
|
||||||
|
TransportModule::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ api_platform:
|
|||||||
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
|
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
|
||||||
# en dehors de Domain/Entity : AuditLogResource, etc.
|
# en dehors de Domain/Entity : AuditLogResource, etc.
|
||||||
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
|
- '%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:
|
formats:
|
||||||
jsonld: ['application/ld+json']
|
jsonld: ['application/ld+json']
|
||||||
json: ['application/json']
|
json: ['application/json']
|
||||||
|
|||||||
@@ -8,16 +8,22 @@ doctrine:
|
|||||||
default:
|
default:
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
profiling_collect_backtrace: '%kernel.debug%'
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
# Exclut `audit_log` de toute operation de comparaison de schema
|
# Exclut certaines tables de toute operation de comparaison de
|
||||||
# (doctrine:schema:update, schema:validate, diff de migrations...).
|
# schema (doctrine:schema:update, schema:validate, diff de
|
||||||
# Cette table n'a volontairement aucune entite mappee : elle est
|
# migrations...). Ces tables n'ont volontairement aucune entite
|
||||||
# append-only via DBAL brut (AuditLogWriter) pour eviter la
|
# mappee :
|
||||||
# recursion du listener Doctrine. Sans ce filtre, schema:update
|
# - `audit_log` : append-only via DBAL brut (AuditLogWriter) pour
|
||||||
# la considere comme "orpheline" et genere un `DROP TABLE
|
# eviter la recursion du listener Doctrine.
|
||||||
# audit_log` qui casse la base de test apres chaque
|
# - `qualimat_carrier` / `qualimat_sync_log` : referentiel
|
||||||
# `make test-db-setup`. La creation / suppression de la table
|
# transporteurs synchronise en DBAL brut (upsert `ON CONFLICT`)
|
||||||
# reste pilotee par les migrations (cf. Version20260420202749).
|
# par `app:qualimat:sync`, hors ORM.
|
||||||
schema_filter: '~^(?!audit_log$).+~'
|
# Sans ce filtre, schema:update les considere comme "orphelines" et
|
||||||
|
# genere un `DROP TABLE` qui casse la base de test apres chaque
|
||||||
|
# `make test-db-setup` (la migration les a creees, schema:update les
|
||||||
|
# supprime juste apres). Creation / suppression restent pilotees par
|
||||||
|
# les migrations (audit_log : Version20260420202749 ; qualimat :
|
||||||
|
# Version20260612150000).
|
||||||
|
schema_filter: '~^(?!(?:audit_log|qualimat_carrier|qualimat_sync_log)$).+~'
|
||||||
audit:
|
audit:
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
orm:
|
orm:
|
||||||
@@ -42,6 +48,16 @@ doctrine:
|
|||||||
# Shared sans importer la classe concrete du module Catalog (regle n°1).
|
# Shared sans importer la classe concrete du module Catalog (regle n°1).
|
||||||
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
|
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
|
||||||
mappings:
|
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:
|
Core:
|
||||||
type: attribute
|
type: attribute
|
||||||
is_bundle: false
|
is_bundle: false
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ doctrine_migrations:
|
|||||||
migrations_paths:
|
migrations_paths:
|
||||||
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||||
'App\Module\Core\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Core/Infrastructure/Doctrine/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
|
enable_profiler: false
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Active le composant HTTP Client (symfony/http-client) et enregistre
|
||||||
|
# l'autowiring de HttpClientInterface. Utilise par les commandes de
|
||||||
|
# synchronisation de referentiels externes (QUALIMAT, IDTF...).
|
||||||
|
framework:
|
||||||
|
http_client:
|
||||||
|
default_options:
|
||||||
|
timeout: 30
|
||||||
|
headers:
|
||||||
|
User-Agent: 'Starseed-ERP (referentiel-sync)'
|
||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.115'
|
app.version: '0.1.125'
|
||||||
|
|||||||
@@ -388,9 +388,105 @@
|
|||||||
"apply": "Voir les résultats",
|
"apply": "Voir les résultats",
|
||||||
"reset": "Réinitialiser"
|
"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": {
|
"toast": {
|
||||||
"error": "Une erreur est survenue. Réessayez.",
|
"error": "Une erreur est survenue. Réessayez.",
|
||||||
"exportError": "L'export du répertoire prestataires a échoué. 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -157,12 +157,16 @@
|
|||||||
<!-- Onglet Contact -->
|
<!-- Onglet Contact -->
|
||||||
<template #contact>
|
<template #contact>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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
|
<ClientContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="contact.id ?? `new-${index}`"
|
:key="contact.id ?? `new-${index}`"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||||
:removable="contacts.length > 1"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -199,7 +203,7 @@
|
|||||||
:site-options="siteOptions"
|
:site-options="siteOptions"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="addresses.length > 1"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -304,7 +308,7 @@
|
|||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -440,6 +444,7 @@ import {
|
|||||||
type RibFormDraft,
|
type RibFormDraft,
|
||||||
} from '~/modules/commercial/types/clientForm'
|
} from '~/modules/commercial/types/clientForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
@@ -490,10 +495,6 @@ const contacts = ref<ContactFormDraft[]>([])
|
|||||||
const addresses = ref<AddressFormDraft[]>([])
|
const addresses = ref<AddressFormDraft[]>([])
|
||||||
const ribs = ref<RibFormDraft[]>([])
|
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 mainSubmitting = ref(false)
|
||||||
const tabSubmitting = ref(false)
|
const tabSubmitting = ref(false)
|
||||||
@@ -754,32 +755,31 @@ function addContact(): void {
|
|||||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
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 {
|
function askRemoveContact(index: number): void {
|
||||||
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
|
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => removeCollectionRow({
|
||||||
const removed = contacts.value[index]
|
rows: contacts.value,
|
||||||
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
errors: contactErrors.value,
|
||||||
contacts.value.splice(index, 1)
|
index,
|
||||||
contactErrors.value.splice(index, 1)
|
endpoint: '/client_contacts',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
makeEmpty: emptyContact,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide l'onglet Contact : DELETE des contacts retires (existants), puis
|
* Valide l'onglet Contact : POST/PATCH des blocs restants sur la sous-ressource.
|
||||||
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
* Strictement scope a la collection contacts (endpoints client_contact dedies). La
|
||||||
* collection contacts (endpoints client_contact dedies).
|
* suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
|
||||||
*/
|
*/
|
||||||
async function submitContacts(): Promise<void> {
|
async function submitContacts(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
contactErrors.value = []
|
contactErrors.value = []
|
||||||
try {
|
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
|
// 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
|
// 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
|
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
|
||||||
@@ -836,14 +836,15 @@ function addAddress(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function askRemoveAddress(index: number): void {
|
function askRemoveAddress(index: number): void {
|
||||||
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
|
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => removeCollectionRow({
|
||||||
const removed = addresses.value[index]
|
rows: addresses.value,
|
||||||
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
errors: addressErrors.value,
|
||||||
addresses.value.splice(index, 1)
|
index,
|
||||||
addressErrors.value.splice(index, 1)
|
endpoint: '/client_addresses',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
makeEmpty: emptyAddress,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAddressDegraded(): void {
|
function onAddressDegraded(): void {
|
||||||
@@ -855,17 +856,12 @@ function onAddressDegraded(): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
|
/** Valide l'onglet Adresse : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
|
||||||
async function submitAddresses(): Promise<void> {
|
async function submitAddresses(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
addressErrors.value = []
|
addressErrors.value = []
|
||||||
try {
|
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).
|
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
|
||||||
const hasError = await submitRows(
|
const hasError = await submitRows(
|
||||||
addresses.value,
|
addresses.value,
|
||||||
@@ -937,29 +933,32 @@ function addRib(): void {
|
|||||||
if (canAddRib.value) ribs.value.push(emptyRib())
|
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 {
|
function askRemoveRib(index: number): void {
|
||||||
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
|
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => removeCollectionRow({
|
||||||
const removed = ribs.value[index]
|
rows: ribs.value,
|
||||||
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
errors: ribErrors.value,
|
||||||
ribs.value.splice(index, 1)
|
index,
|
||||||
ribErrors.value.splice(index, 1)
|
endpoint: '/client_ribs',
|
||||||
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
makeEmpty: emptyRib,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
||||||
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
|
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
|
||||||
* back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back
|
* back). Les RIB crees d'abord : le back valide RG-1.13 (LCR => au moins un RIB
|
||||||
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
* 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
|
* 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
|
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||||
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-1.28 :
|
||||||
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement
|
* sinon 403 sur tout le payload).
|
||||||
* 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> {
|
async function submitAccounting(): Promise<void> {
|
||||||
if (accountingReadonly.value || tabSubmitting.value) return
|
if (accountingReadonly.value || tabSubmitting.value) return
|
||||||
@@ -1013,14 +1012,6 @@ async function submitAccounting(): Promise<void> {
|
|||||||
return
|
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') })
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
|
|||||||
@@ -156,12 +156,16 @@
|
|||||||
<!-- Onglet Contact -->
|
<!-- Onglet Contact -->
|
||||||
<template #contact>
|
<template #contact>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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
|
<ClientContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="isValidated('contact')"
|
:readonly="isValidated('contact')"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -198,7 +202,7 @@
|
|||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="isValidated('address')"
|
:readonly="isValidated('address')"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -303,7 +307,7 @@
|
|||||||
>
|
>
|
||||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -417,6 +421,7 @@ import {
|
|||||||
type RibFormDraft,
|
type RibFormDraft,
|
||||||
} from '~/modules/commercial/types/clientForm'
|
} from '~/modules/commercial/types/clientForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
|
|||||||
@@ -126,12 +126,16 @@
|
|||||||
<!-- Onglet Contacts -->
|
<!-- Onglet Contacts -->
|
||||||
<template #contacts>
|
<template #contacts>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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
|
<SupplierContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="contact.id ?? `new-${index}`"
|
:key="contact.id ?? `new-${index}`"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||||
:removable="contacts.length > 1"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -168,7 +172,7 @@
|
|||||||
:site-options="siteOptions"
|
:site-options="siteOptions"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="addresses.length > 1"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -273,7 +277,7 @@
|
|||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -407,6 +411,7 @@ import {
|
|||||||
type SupplierRibFormDraft,
|
type SupplierRibFormDraft,
|
||||||
} from '~/modules/commercial/types/supplierForm'
|
} from '~/modules/commercial/types/supplierForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
@@ -456,10 +461,6 @@ const contacts = ref<SupplierContactFormDraft[]>([])
|
|||||||
const addresses = ref<SupplierAddressFormDraft[]>([])
|
const addresses = ref<SupplierAddressFormDraft[]>([])
|
||||||
const ribs = ref<SupplierRibFormDraft[]>([])
|
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 mainSubmitting = ref(false)
|
||||||
const tabSubmitting = ref(false)
|
const tabSubmitting = ref(false)
|
||||||
@@ -653,32 +654,31 @@ function addContact(): void {
|
|||||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
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 {
|
function askRemoveContact(index: number): void {
|
||||||
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => {
|
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => removeCollectionRow({
|
||||||
const removed = contacts.value[index]
|
rows: contacts.value,
|
||||||
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
errors: contactErrors.value,
|
||||||
contacts.value.splice(index, 1)
|
index,
|
||||||
contactErrors.value.splice(index, 1)
|
endpoint: '/supplier_contacts',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
makeEmpty: emptyContact,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valide l'onglet Contacts : DELETE des contacts retires (existants), puis
|
* Valide l'onglet Contacts : POST/PATCH des blocs restants sur la sous-ressource.
|
||||||
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
* Strictement scope a la collection contacts (endpoints supplier_contact dedies).
|
||||||
* collection contacts (endpoints supplier_contact dedies).
|
* La suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
|
||||||
*/
|
*/
|
||||||
async function submitContacts(): Promise<void> {
|
async function submitContacts(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
contactErrors.value = []
|
contactErrors.value = []
|
||||||
try {
|
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
|
// 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).
|
// 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))
|
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
|
||||||
@@ -726,14 +726,15 @@ function addAddress(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function askRemoveAddress(index: number): void {
|
function askRemoveAddress(index: number): void {
|
||||||
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => {
|
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => removeCollectionRow({
|
||||||
const removed = addresses.value[index]
|
rows: addresses.value,
|
||||||
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
errors: addressErrors.value,
|
||||||
addresses.value.splice(index, 1)
|
index,
|
||||||
addressErrors.value.splice(index, 1)
|
endpoint: '/supplier_addresses',
|
||||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
makeEmpty: emptyAddress,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAddressDegraded(): void {
|
function onAddressDegraded(): void {
|
||||||
@@ -745,17 +746,12 @@ function onAddressDegraded(): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Valide l'onglet Adresses : DELETE des adresses retirees puis POST/PATCH. */
|
/** Valide l'onglet Adresses : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
|
||||||
async function submitAddresses(): Promise<void> {
|
async function submitAddresses(): Promise<void> {
|
||||||
if (businessReadonly.value || tabSubmitting.value) return
|
if (businessReadonly.value || tabSubmitting.value) return
|
||||||
tabSubmitting.value = true
|
tabSubmitting.value = true
|
||||||
addressErrors.value = []
|
addressErrors.value = []
|
||||||
try {
|
try {
|
||||||
for (const id of removedAddressIds.value) {
|
|
||||||
await api.delete(`/supplier_addresses/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
removedAddressIds.value = []
|
|
||||||
|
|
||||||
const hasError = await submitRows(
|
const hasError = await submitRows(
|
||||||
addresses.value,
|
addresses.value,
|
||||||
addressErrors,
|
addressErrors,
|
||||||
@@ -826,15 +822,18 @@ function addRib(): void {
|
|||||||
if (canAddRib.value) ribs.value.push(emptyRib())
|
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 {
|
function askRemoveRib(index: number): void {
|
||||||
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => {
|
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => removeCollectionRow({
|
||||||
const removed = ribs.value[index]
|
rows: ribs.value,
|
||||||
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
errors: ribErrors.value,
|
||||||
ribs.value.splice(index, 1)
|
index,
|
||||||
ribErrors.value.splice(index, 1)
|
endpoint: '/supplier_ribs',
|
||||||
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
|
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
makeEmpty: emptyRib,
|
||||||
})
|
onError: showError,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -843,11 +842,12 @@ function askRemoveRib(index: number): void {
|
|||||||
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
|
* 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.
|
* 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
|
* 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
|
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||||
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-2.16 :
|
||||||
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le
|
* sinon 403 sur tout le payload).
|
||||||
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
|
|
||||||
*/
|
*/
|
||||||
async function submitAccounting(): Promise<void> {
|
async function submitAccounting(): Promise<void> {
|
||||||
if (accountingReadonly.value || tabSubmitting.value) return
|
if (accountingReadonly.value || tabSubmitting.value) return
|
||||||
@@ -897,14 +897,6 @@ async function submitAccounting(): Promise<void> {
|
|||||||
return
|
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') })
|
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
|
|||||||
@@ -121,12 +121,16 @@
|
|||||||
<!-- Onglet Contacts -->
|
<!-- Onglet Contacts -->
|
||||||
<template #contacts>
|
<template #contacts>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<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
|
<SupplierContactBlock
|
||||||
v-for="(contact, index) in contacts"
|
v-for="(contact, index) in contacts"
|
||||||
:key="index"
|
:key="index"
|
||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(contacts, index)"
|
||||||
:readonly="isValidated('contacts')"
|
:readonly="isValidated('contacts')"
|
||||||
:errors="contactErrors[index]"
|
:errors="contactErrors[index]"
|
||||||
@update:model-value="(v) => contacts[index] = v"
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
@@ -163,7 +167,7 @@
|
|||||||
:site-options="referentials.sites.value"
|
:site-options="referentials.sites.value"
|
||||||
:contact-options="contactOptions"
|
:contact-options="contactOptions"
|
||||||
:country-options="countryOptions"
|
:country-options="countryOptions"
|
||||||
:removable="index > 0"
|
:removable="isRowRemovable(addresses, index)"
|
||||||
:readonly="isValidated('addresses')"
|
:readonly="isValidated('addresses')"
|
||||||
:errors="addressErrors[index]"
|
:errors="addressErrors[index]"
|
||||||
@update:model-value="(v) => addresses[index] = v"
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
@@ -267,7 +271,7 @@
|
|||||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
>
|
>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
button-class="absolute top-3 right-3"
|
button-class="absolute top-3 right-3"
|
||||||
@@ -380,6 +384,7 @@ import {
|
|||||||
type SupplierRibFormDraft,
|
type SupplierRibFormDraft,
|
||||||
} from '~/modules/commercial/types/supplierForm'
|
} from '~/modules/commercial/types/supplierForm'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||||
|
|
||||||
// Masques de saisie (la normalisation finale reste serveur).
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
const SIREN_MASK = '#########'
|
const SIREN_MASK = '#########'
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||||
|
<!-- Suppression : modal de confirmation cote parent. -->
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="removable && !readonly"
|
||||||
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
|
button-class="absolute top-3 right-3"
|
||||||
|
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
|
||||||
|
@click="$emit('remove')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="model.siteIris"
|
||||||
|
:options="siteOptions"
|
||||||
|
:label="t('technique.providers.form.address.sites')"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:required="true"
|
||||||
|
:error="errors?.sites"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Categories de type PRESTATAIRE (>= 1 obligatoire, RG-3.09). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="model.categoryIris"
|
||||||
|
:options="categoryOptions"
|
||||||
|
:label="t('technique.providers.form.address.categories')"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:required="true"
|
||||||
|
:error="errors?.categories"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="model.contactIris"
|
||||||
|
:options="contactOptions"
|
||||||
|
:label="t('technique.providers.form.address.contacts')"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="model.country"
|
||||||
|
:options="countryOptions"
|
||||||
|
:label="t('technique.providers.form.address.country')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:required="true"
|
||||||
|
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.postalCode"
|
||||||
|
:label="t('technique.providers.form.address.postalCode')"
|
||||||
|
:mask="POSTAL_CODE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:required="true"
|
||||||
|
:error="errors?.postalCode"
|
||||||
|
@update:model-value="onPostalCodeChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
||||||
|
<MalioSelect
|
||||||
|
v-if="!degraded"
|
||||||
|
:model-value="model.city"
|
||||||
|
:options="cityOptions"
|
||||||
|
:label="t('technique.providers.form.address.city')"
|
||||||
|
:readonly="readonly"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:error="errors?.city"
|
||||||
|
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-else
|
||||||
|
:model-value="model.city"
|
||||||
|
:label="t('technique.providers.form.address.city')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:required="true"
|
||||||
|
:error="errors?.city"
|
||||||
|
@update:model-value="(v: string) => update('city', v)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
||||||
|
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||||
|
<div class="col-span-2">
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-if="!readonly"
|
||||||
|
:model-value="model.street"
|
||||||
|
:options="addressOptions"
|
||||||
|
:loading="addressLoading"
|
||||||
|
:min-search-length="3"
|
||||||
|
:label="t('technique.providers.form.address.street')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:required="true"
|
||||||
|
:error="errors?.street"
|
||||||
|
:allow-create="true"
|
||||||
|
:no-results-text="t('technique.providers.form.address.streetNotFound')"
|
||||||
|
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||||
|
@search="onAddressSearch"
|
||||||
|
@select="onAddressSelect"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-else
|
||||||
|
:model-value="model.street"
|
||||||
|
:label="t('technique.providers.form.address.street')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:required="true"
|
||||||
|
:error="errors?.street"
|
||||||
|
@update:model-value="(v: string) => update('street', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-1">
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.streetComplement"
|
||||||
|
:label="t('technique.providers.form.address.streetComplement')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.streetComplement"
|
||||||
|
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||||
|
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||||
|
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
|
||||||
|
|
||||||
|
// Masque code postal FR : 5 chiffres.
|
||||||
|
const POSTAL_CODE_MASK = '#####'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Brouillon de l'adresse (v-model). */
|
||||||
|
modelValue: ProviderAddressFormDraft
|
||||||
|
/** Categories autorisees sur une adresse (type PRESTATAIRE). */
|
||||||
|
categoryOptions: RefOption[]
|
||||||
|
/** Sites Starseed disponibles. */
|
||||||
|
siteOptions: RefOption[]
|
||||||
|
/** Contacts deja saisis, rattachables a l'adresse. */
|
||||||
|
contactOptions: RefOption[]
|
||||||
|
/** Pays disponibles (France par defaut). */
|
||||||
|
countryOptions: RefOption[]
|
||||||
|
removable?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||||
|
errors?: Record<string, string>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: ProviderAddressFormDraft]
|
||||||
|
'remove': []
|
||||||
|
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
|
||||||
|
'degraded': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const autocomplete = useAddressAutocomplete()
|
||||||
|
|
||||||
|
const model = computed(() => props.modelValue)
|
||||||
|
|
||||||
|
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable).
|
||||||
|
const degraded = ref(false)
|
||||||
|
let unavailableNotified = false
|
||||||
|
const banCityOptions = ref<RefOption[]>([])
|
||||||
|
const banAddressOptions = ref<RefOption[]>([])
|
||||||
|
|
||||||
|
// Options ville effectives : on garantit que la ville courante figure toujours
|
||||||
|
// dans la liste, sinon MalioSelect afficherait un champ vide en lecture seule.
|
||||||
|
const cityOptions = computed<RefOption[]>(() => {
|
||||||
|
const current = props.modelValue.city
|
||||||
|
if (current && !banCityOptions.value.some(o => o.value === current)) {
|
||||||
|
return [{ value: current, label: current }, ...banCityOptions.value]
|
||||||
|
}
|
||||||
|
return banCityOptions.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Meme garantie pour le champ Adresse : la rue courante doit toujours figurer
|
||||||
|
// dans les options, sinon MalioInputAutocomplete laisse le champ vide.
|
||||||
|
const addressOptions = computed<RefOption[]>(() => {
|
||||||
|
const current = props.modelValue.street
|
||||||
|
if (current && !banAddressOptions.value.some(o => o.value === current)) {
|
||||||
|
return [{ value: current, label: current }, ...banAddressOptions.value]
|
||||||
|
}
|
||||||
|
return banAddressOptions.value
|
||||||
|
})
|
||||||
|
const addressLoading = ref(false)
|
||||||
|
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||||
|
let lastAddressSuggestions: AddressSuggestion[] = []
|
||||||
|
|
||||||
|
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||||
|
function update<K extends keyof ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
||||||
|
function notifyUnavailable(): void {
|
||||||
|
if (!unavailableNotified) {
|
||||||
|
unavailableNotified = true
|
||||||
|
emit('degraded')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville (RG-3.06). */
|
||||||
|
async function onPostalCodeChange(value: string): Promise<void> {
|
||||||
|
update('postalCode', value)
|
||||||
|
|
||||||
|
const digits = (value ?? '').replace(/\D/g, '')
|
||||||
|
if (digits.length < 5) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const suggestions = await autocomplete.searchCity(digits)
|
||||||
|
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
|
||||||
|
degraded.value = false
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
degraded.value = true
|
||||||
|
notifyUnavailable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
|
||||||
|
async function onAddressSearch(query: string): Promise<void> {
|
||||||
|
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400).
|
||||||
|
if (query.trim().length < 3) {
|
||||||
|
banAddressOptions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addressLoading.value = true
|
||||||
|
try {
|
||||||
|
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
|
||||||
|
const suggestions = await autocomplete.searchAddress(query, postalCode)
|
||||||
|
lastAddressSuggestions = suggestions
|
||||||
|
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie.
|
||||||
|
banAddressOptions.value = []
|
||||||
|
notifyUnavailable()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
addressLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selection d'une suggestion d'adresse → remplit rue + ville + CP. */
|
||||||
|
function onAddressSelect(option: { label: string, value: string | number } | null): void {
|
||||||
|
if (option === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const suggestion = lastAddressSuggestions.find(s => s.street === option.value)
|
||||||
|
if (!suggestion) {
|
||||||
|
update('street', String(option.value))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
street: suggestion.street,
|
||||||
|
city: suggestion.city,
|
||||||
|
postalCode: suggestion.postalCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||||
|
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||||
|
non supprimable (1er bloc) ou en lecture seule. -->
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="removable && !readonly"
|
||||||
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
|
button-class="absolute top-3 right-3"
|
||||||
|
v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }"
|
||||||
|
@click="$emit('remove')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.lastName"
|
||||||
|
:label="t('technique.providers.form.contact.lastName')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.lastName"
|
||||||
|
@update:model-value="(v: string) => update('lastName', v)"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.firstName"
|
||||||
|
:label="t('technique.providers.form.contact.firstName')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.firstName"
|
||||||
|
@update:model-value="(v: string) => update('firstName', v)"
|
||||||
|
/>
|
||||||
|
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||||
|
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||||
|
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||||
|
<div class="col-span-2">
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.jobTitle"
|
||||||
|
:label="t('technique.providers.form.contact.jobTitle')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.jobTitle"
|
||||||
|
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<MalioInputEmail
|
||||||
|
:model-value="model.email"
|
||||||
|
:label="t('technique.providers.form.contact.email')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:lowercase="true"
|
||||||
|
:error="errors?.email"
|
||||||
|
@update:model-value="(v: string) => update('email', v)"
|
||||||
|
/>
|
||||||
|
<MalioInputPhone
|
||||||
|
:model-value="model.phonePrimary"
|
||||||
|
:label="t('technique.providers.form.contact.phonePrimary')"
|
||||||
|
:mask="PHONE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.phonePrimary"
|
||||||
|
:addable="!model.hasSecondaryPhone && !readonly"
|
||||||
|
:add-button-label="t('technique.providers.form.contact.addPhone')"
|
||||||
|
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||||
|
@add="revealSecondaryPhone"
|
||||||
|
/>
|
||||||
|
<!-- 2e numero : revele a la demande (max 2 telephones par contact). -->
|
||||||
|
<MalioInputPhone
|
||||||
|
v-if="model.hasSecondaryPhone"
|
||||||
|
:model-value="model.phoneSecondary"
|
||||||
|
:label="t('technique.providers.form.contact.phoneSecondary')"
|
||||||
|
:mask="PHONE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.phoneSecondary"
|
||||||
|
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
|
||||||
|
|
||||||
|
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
|
||||||
|
const PHONE_MASK = '## ## ## ## ##'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Brouillon du contact (v-model). */
|
||||||
|
modelValue: ProviderContactFormDraft
|
||||||
|
/** Affiche l'icone de suppression (1er bloc non supprimable). */
|
||||||
|
removable?: boolean
|
||||||
|
/** Bloc en lecture seule (onglet valide). */
|
||||||
|
readonly?: boolean
|
||||||
|
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||||
|
errors?: Record<string, string>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: ProviderContactFormDraft]
|
||||||
|
'remove': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// Alias local pour la lisibilite du template.
|
||||||
|
const model = computed(() => props.modelValue)
|
||||||
|
|
||||||
|
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||||
|
function update<K extends keyof ProviderContactFormDraft>(field: K, value: ProviderContactFormDraft[K]): void {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revele le 2e numero (max 1 secondaire, le « + » disparait). */
|
||||||
|
function revealSecondaryPhone(): void {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, hasSecondaryPhone: true })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, ref, computed } from 'vue'
|
||||||
|
import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
|
||||||
|
import ProviderAddressBlock from '../ProviderAddressBlock.vue'
|
||||||
|
|
||||||
|
// Mocks controlables du composable BAN (hoisted), reutilise tel quel du M1/M2.
|
||||||
|
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
|
||||||
|
searchCityMock: vi.fn(),
|
||||||
|
searchAddressMock: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
|
||||||
|
useAddressAutocomplete: () => ({
|
||||||
|
searchCity: searchCityMock,
|
||||||
|
searchAddress: searchAddressMock,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('ref', ref)
|
||||||
|
vi.stubGlobal('computed', computed)
|
||||||
|
|
||||||
|
// Stub de MalioInputAutocomplete : expose les `value` des options + allowCreate.
|
||||||
|
const MalioInputAutocompleteStub = defineComponent({
|
||||||
|
name: 'MalioInputAutocomplete',
|
||||||
|
props: {
|
||||||
|
modelValue: { type: [String, Number, null], default: undefined },
|
||||||
|
options: { type: Array as () => { value: string | number, label: string }[], default: () => [] },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
minSearchLength: { type: Number, default: 0 },
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
readonly: { type: Boolean, default: false },
|
||||||
|
allowCreate: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue', 'search', 'select'],
|
||||||
|
setup(props) {
|
||||||
|
return () => h('div', {
|
||||||
|
'data-testid': 'addr-autocomplete',
|
||||||
|
'data-options': JSON.stringify(props.options.map(o => o.value)),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<string, string>) {
|
||||||
|
return mount(ProviderAddressBlock, {
|
||||||
|
props: {
|
||||||
|
modelValue: { ...emptyProviderAddress(), ...overrides },
|
||||||
|
categoryOptions: [],
|
||||||
|
siteOptions: [],
|
||||||
|
contactOptions: [],
|
||||||
|
countryOptions: [],
|
||||||
|
...(errors ? { errors } : {}),
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
MalioButtonIcon: true,
|
||||||
|
MalioSelect: true,
|
||||||
|
MalioSelectCheckbox: true,
|
||||||
|
MalioInputText: true,
|
||||||
|
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProviderAddressBlock — version simplifiee M3 (pas de type/bennes/triage)', () => {
|
||||||
|
it('ne rend NI type d\'adresse, NI bennes, NI prestation de triage (difference M2)', () => {
|
||||||
|
const wrapper = mountBlock()
|
||||||
|
// Pas de stepper (bennes) ni de case a cocher (triage) dans le bloc M3.
|
||||||
|
expect(wrapper.find('malio-input-number-stub').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('malio-checkbox-stub').exists()).toBe(false)
|
||||||
|
// Aucun select ne porte le label « type d'adresse ».
|
||||||
|
const hasAddressType = wrapper.findAll('malio-select-stub').some(
|
||||||
|
el => el.attributes('label') === 'technique.providers.form.address.addressType',
|
||||||
|
)
|
||||||
|
expect(hasAddressType).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ProviderAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
||||||
|
it('affiche les erreurs serveur sur sites et categories (RG-3.05 / RG-3.09)', () => {
|
||||||
|
const wrapper = mountBlock({}, {
|
||||||
|
sites: 'Au moins un site est obligatoire.',
|
||||||
|
categories: 'Au moins une catégorie est obligatoire.',
|
||||||
|
})
|
||||||
|
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
|
||||||
|
const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites')
|
||||||
|
const categoriesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.categories')
|
||||||
|
|
||||||
|
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
|
||||||
|
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche l\'erreur serveur sur le code postal', () => {
|
||||||
|
const wrapper = mountBlock({}, { postalCode: 'Code postal invalide.' })
|
||||||
|
const field = wrapper.findAll('malio-input-text-stub').find(
|
||||||
|
el => el.attributes('label') === 'technique.providers.form.address.postalCode',
|
||||||
|
)
|
||||||
|
expect(field?.attributes('error')).toBe('Code postal invalide.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ProviderAddressBlock — autocompletion BAN (RG-3.06)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
searchCityMock.mockReset()
|
||||||
|
searchAddressMock.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'appelle pas la BAN en deca de 3 caracteres', async () => {
|
||||||
|
const wrapper = mountBlock()
|
||||||
|
wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab')
|
||||||
|
await flushPromises()
|
||||||
|
expect(searchAddressMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('relance la recherche apres une erreur (pas de bascule definitive)', async () => {
|
||||||
|
searchAddressMock
|
||||||
|
.mockRejectedValueOnce(new Error('BAN indisponible'))
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ label: '1 rue du Test, Châtellerault', street: '1 rue du Test', postalCode: '86100', city: 'Châtellerault' },
|
||||||
|
])
|
||||||
|
const wrapper = mountBlock()
|
||||||
|
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||||
|
|
||||||
|
auto.vm.$emit('search', 'rue du test')
|
||||||
|
await flushPromises()
|
||||||
|
auto.vm.$emit('search', 'rue du teste')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(searchAddressMock).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cas degrade : la BAN echoue -> emet « degraded » une seule fois (RG-3.06)', async () => {
|
||||||
|
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
|
||||||
|
const wrapper = mountBlock()
|
||||||
|
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||||
|
|
||||||
|
auto.vm.$emit('search', 'rue du test')
|
||||||
|
await flushPromises()
|
||||||
|
auto.vm.$emit('search', 'rue du teste')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('degraded')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
|
||||||
|
const wrapper = mountBlock()
|
||||||
|
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inclut la rue courante dans les options meme sans recherche BAN', () => {
|
||||||
|
const wrapper = mountBlock({ street: '1 rue du Test' })
|
||||||
|
const values = JSON.parse(wrapper.find('[data-testid="addr-autocomplete"]').attributes('data-options') ?? '[]')
|
||||||
|
expect(values).toContain('1 rue du Test')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, ref, computed } from 'vue'
|
||||||
|
import { emptyProviderContact } from '~/modules/technique/types/providerForm'
|
||||||
|
import ProviderContactBlock from '../ProviderContactBlock.vue'
|
||||||
|
|
||||||
|
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('ref', ref)
|
||||||
|
vi.stubGlobal('computed', computed)
|
||||||
|
|
||||||
|
/** Stub d'un champ Malio qui re-expose la prop `error` recue dans un data-* attribut. */
|
||||||
|
function errorProbe(testid: string) {
|
||||||
|
return defineComponent({
|
||||||
|
name: `Probe-${testid}`,
|
||||||
|
props: {
|
||||||
|
modelValue: { type: [String, Number, null], default: undefined },
|
||||||
|
error: { type: String, default: '' },
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
readonly: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
return () => h('div', { 'data-testid': testid, 'data-error': props.error })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountBlock(errors?: Record<string, string>) {
|
||||||
|
return mount(ProviderContactBlock, {
|
||||||
|
props: {
|
||||||
|
modelValue: emptyProviderContact(),
|
||||||
|
...(errors ? { errors } : {}),
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
MalioButtonIcon: true,
|
||||||
|
MalioInputPhone: true,
|
||||||
|
MalioInputText: errorProbe('contact-text'),
|
||||||
|
MalioInputEmail: errorProbe('contact-email'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProviderContactBlock — mapping erreur par champ (ERP-101)', () => {
|
||||||
|
it('affiche l\'erreur serveur sur le champ email via la prop errors', () => {
|
||||||
|
const wrapper = mountBlock({ email: 'L\'adresse email n\'est pas valide.' })
|
||||||
|
expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('L\'adresse email n\'est pas valide.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('laisse les champs sans erreur quand errors est absent', () => {
|
||||||
|
const wrapper = mountBlock()
|
||||||
|
expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,653 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du workflow « Ajouter un prestataire » (M3 Technique, ERP-141).
|
||||||
|
*
|
||||||
|
* `useProviderForm` porte le formulaire principal (Nom + Categorie + Site) et
|
||||||
|
* l'orchestration des onglets de creation. On verifie ici le CONTRAT propre a la
|
||||||
|
* creation :
|
||||||
|
* - RG-3.03 (front) : au moins un site requis ; RG-3.09 : au moins une categorie
|
||||||
|
* -> POST bloque, erreurs inline, aucun appel reseau.
|
||||||
|
* - POST /providers (groupe provider:write:main) : payload IRIs + Accept ld+json
|
||||||
|
* + toast:false ; au succes, verrouillage + bascule sur l'onglet Contact +
|
||||||
|
* reaffichage du nom normalise.
|
||||||
|
* - 409 doublon (RG-3.10) -> erreur inline dediee sur companyName.
|
||||||
|
* - 422 -> mapping inline par champ (propertyPath).
|
||||||
|
* - Onglets : « Comptabilite » present uniquement avec accounting.view ;
|
||||||
|
* completeTab deverrouille/avance et signale le dernier onglet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const mockPost = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPatch = vi.hoisted(() => vi.fn())
|
||||||
|
// Permissions comptables pilotables par test (presence/edition de l'onglet Comptabilite).
|
||||||
|
const permState = vi.hoisted(() => ({ accountingView: false, accountingManage: false }))
|
||||||
|
|
||||||
|
vi.stubGlobal('useApi', () => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
post: mockPost,
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: mockPatch,
|
||||||
|
delete: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('useToast', () => ({
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.stubGlobal('usePermissions', () => ({
|
||||||
|
can: (perm: string) => {
|
||||||
|
if (perm === 'technique.providers.accounting.view') return permState.accountingView
|
||||||
|
if (perm === 'technique.providers.accounting.manage') return permState.accountingManage
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm')
|
||||||
|
const { emptyProviderContact, emptyProviderAddress } = await import('~/modules/technique/types/providerForm')
|
||||||
|
type ProviderForm = ReturnType<typeof useProviderForm>
|
||||||
|
|
||||||
|
const SITE_86 = '/api/sites/1'
|
||||||
|
const CAT_MAINT = '/api/categories/7'
|
||||||
|
|
||||||
|
/** Accede a un bloc contact (cast : sous noUncheckedIndexedAccess l'index est optionnel). */
|
||||||
|
function contactAt(form: ProviderForm, index = 0) {
|
||||||
|
return form.contacts.value[index] ?? emptyProviderContact()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accede a un bloc adresse (idem). */
|
||||||
|
function addressAt(form: ProviderForm, index = 0) {
|
||||||
|
return form.addresses.value[index] ?? emptyProviderAddress()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useProviderForm', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPost.mockReset()
|
||||||
|
mockPatch.mockReset()
|
||||||
|
permState.accountingView = false
|
||||||
|
permState.accountingManage = false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('front : formulaire principal vide -> erreurs sur nom + site + categorie, pas de POST', async () => {
|
||||||
|
const form = useProviderForm()
|
||||||
|
|
||||||
|
const created = await form.submitMain()
|
||||||
|
|
||||||
|
expect(created).toBe(false)
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
|
||||||
|
expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
|
||||||
|
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
|
||||||
|
expect(form.mainLocked.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('RG-3.03 (front) : un site present sans categorie n\'erre que sur categories', async () => {
|
||||||
|
const form = useProviderForm()
|
||||||
|
form.main.companyName = 'Maintenance Pro'
|
||||||
|
form.main.siteIris = [SITE_86]
|
||||||
|
|
||||||
|
await form.submitMain()
|
||||||
|
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(form.mainErrors.errors.sites).toBeUndefined()
|
||||||
|
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('POST /providers avec IRIs + Accept ld+json, verrouille et bascule sur Contact', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({ id: 42, companyName: 'MAINTENANCE PRO' })
|
||||||
|
const form = useProviderForm()
|
||||||
|
form.main.companyName = 'Maintenance Pro'
|
||||||
|
form.main.categoryIris = [CAT_MAINT]
|
||||||
|
form.main.siteIris = [SITE_86]
|
||||||
|
|
||||||
|
const created = await form.submitMain()
|
||||||
|
|
||||||
|
expect(created).toBe(true)
|
||||||
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||||
|
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||||
|
expect(url).toBe('/providers')
|
||||||
|
expect(body).toEqual({
|
||||||
|
companyName: 'Maintenance Pro',
|
||||||
|
categories: [CAT_MAINT],
|
||||||
|
sites: [SITE_86],
|
||||||
|
})
|
||||||
|
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||||
|
|
||||||
|
expect(form.providerId.value).toBe(42)
|
||||||
|
// RG-3.11 : reaffiche le nom normalise (UPPERCASE) renvoye par le serveur.
|
||||||
|
expect(form.main.companyName).toBe('MAINTENANCE PRO')
|
||||||
|
expect(form.mainLocked.value).toBe(true)
|
||||||
|
expect(form.activeTab.value).toBe('contact')
|
||||||
|
expect(form.unlockedIndex.value).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('front : nom vide/espaces -> erreur inline sur companyName, pas de POST', async () => {
|
||||||
|
const form = useProviderForm()
|
||||||
|
form.main.companyName = ' '
|
||||||
|
form.main.categoryIris = [CAT_MAINT]
|
||||||
|
form.main.siteIris = [SITE_86]
|
||||||
|
|
||||||
|
const created = await form.submitMain()
|
||||||
|
|
||||||
|
expect(created).toBe(false)
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({ response: { status: 409 } })
|
||||||
|
const form = useProviderForm()
|
||||||
|
form.main.companyName = 'Doublon'
|
||||||
|
form.main.categoryIris = [CAT_MAINT]
|
||||||
|
form.main.siteIris = [SITE_86]
|
||||||
|
|
||||||
|
const created = await form.submitMain()
|
||||||
|
|
||||||
|
expect(created).toBe(false)
|
||||||
|
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany')
|
||||||
|
expect(form.mainLocked.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('422 : mappe les violations serveur inline par champ', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
_data: { violations: [{ propertyPath: 'sites', message: 'Au moins un site est requis.' }] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const form = useProviderForm()
|
||||||
|
form.main.companyName = 'X'
|
||||||
|
form.main.categoryIris = [CAT_MAINT]
|
||||||
|
form.main.siteIris = [SITE_86]
|
||||||
|
|
||||||
|
const created = await form.submitMain()
|
||||||
|
|
||||||
|
expect(created).toBe(false)
|
||||||
|
expect(form.mainErrors.errors.sites).toBe('Au moins un site est requis.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onglet Comptabilite : absent sans accounting.view, present avec', () => {
|
||||||
|
expect(buildProviderCreateTabKeys(false)).toEqual(['contact', 'address'])
|
||||||
|
expect(buildProviderCreateTabKeys(true)).toEqual(['contact', 'address', 'accounting'])
|
||||||
|
|
||||||
|
permState.accountingView = true
|
||||||
|
const form = useProviderForm()
|
||||||
|
expect(form.tabKeys.value).toEqual(['contact', 'address', 'accounting'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('completeTab : deverrouille/avance, et signale le dernier onglet du flux', () => {
|
||||||
|
const form = useProviderForm()
|
||||||
|
|
||||||
|
// Contact -> Adresse (pas le dernier).
|
||||||
|
expect(form.completeTab('contact')).toBe(false)
|
||||||
|
expect(form.isValidated('contact')).toBe(true)
|
||||||
|
expect(form.activeTab.value).toBe('address')
|
||||||
|
expect(form.unlockedIndex.value).toBe(1)
|
||||||
|
|
||||||
|
// Adresse = dernier onglet remplissable (sans accounting.view) -> true.
|
||||||
|
expect(form.completeTab('address')).toBe(true)
|
||||||
|
expect(form.isValidated('address')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('patchProvider : PATCH /providers/{id} en mode strict, no-op avant creation', async () => {
|
||||||
|
const form = useProviderForm()
|
||||||
|
|
||||||
|
await form.patchProvider({ siren: '123456789' })
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
mockPost.mockResolvedValueOnce({ id: 9, companyName: 'ACME' })
|
||||||
|
form.main.companyName = 'Acme'
|
||||||
|
form.main.categoryIris = [CAT_MAINT]
|
||||||
|
form.main.siteIris = [SITE_86]
|
||||||
|
await form.submitMain()
|
||||||
|
|
||||||
|
await form.patchProvider({ siren: '123456789' })
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/providers/9', { siren: '123456789' }, { toast: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useProviderForm — onglet Contact (ERP-142)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPost.mockReset()
|
||||||
|
mockPatch.mockReset()
|
||||||
|
permState.accountingView = false
|
||||||
|
permState.accountingManage = false
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Place le formulaire en etat « prestataire cree » (onglet Contact accessible). */
|
||||||
|
function createdForm() {
|
||||||
|
const form = useProviderForm()
|
||||||
|
form.providerId.value = 7
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
it('RG-3.04 : « + Nouveau contact » desactive tant que le dernier bloc est vide', () => {
|
||||||
|
const form = createdForm()
|
||||||
|
expect(form.canAddContact.value).toBe(false)
|
||||||
|
|
||||||
|
// addContact est un no-op tant que le bloc est vide.
|
||||||
|
form.addContact()
|
||||||
|
expect(form.contacts.value).toHaveLength(1)
|
||||||
|
|
||||||
|
contactAt(form).lastName = 'Doe'
|
||||||
|
expect(form.canAddContact.value).toBe(true)
|
||||||
|
form.addContact()
|
||||||
|
expect(form.contacts.value).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removeContact retire le bloc et son erreur de ligne', () => {
|
||||||
|
const form = createdForm()
|
||||||
|
contactAt(form).lastName = 'Doe'
|
||||||
|
form.addContact()
|
||||||
|
form.contactErrors.value = [{}, { lastName: 'x' }]
|
||||||
|
|
||||||
|
form.removeContact(1)
|
||||||
|
expect(form.contacts.value).toHaveLength(1)
|
||||||
|
expect(form.contactErrors.value).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submitContacts : POST des nouveaux, capture id + IRI, finalise l\'onglet', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({ '@id': '/api/provider_contacts/55', id: 55 })
|
||||||
|
const form = createdForm()
|
||||||
|
contactAt(form).lastName = 'Doe'
|
||||||
|
|
||||||
|
const ok = await form.submitContacts(vi.fn())
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||||
|
expect(url).toBe('/providers/7/contacts')
|
||||||
|
expect(body).toMatchObject({ lastName: 'Doe' })
|
||||||
|
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||||
|
expect(contactAt(form).id).toBe(55)
|
||||||
|
expect(contactAt(form).iri).toBe('/api/provider_contacts/55')
|
||||||
|
expect(form.isValidated('contact')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submitContacts : PATCH des contacts existants sur /provider_contacts/{id}', async () => {
|
||||||
|
mockPatch.mockResolvedValueOnce({})
|
||||||
|
const form = createdForm()
|
||||||
|
contactAt(form).id = 55
|
||||||
|
contactAt(form).lastName = 'Doe'
|
||||||
|
|
||||||
|
await form.submitContacts(vi.fn())
|
||||||
|
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/provider_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('RG-3.12 : onglet vide -> soumet l\'amorce pour declencher la 422 firstName inline', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
_data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const form = createdForm()
|
||||||
|
|
||||||
|
const ok = await form.submitContacts(vi.fn())
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||||
|
expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.')
|
||||||
|
expect(form.isValidated('contact')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe les erreurs 422 PAR LIGNE (le bloc 2 echoue, le bloc 1 passe)', async () => {
|
||||||
|
mockPost
|
||||||
|
.mockResolvedValueOnce({ '@id': '/api/provider_contacts/1', id: 1 })
|
||||||
|
.mockRejectedValueOnce({
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
_data: { violations: [{ propertyPath: 'email', message: 'L\'adresse email n\'est pas valide.' }] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const form = createdForm()
|
||||||
|
contactAt(form).lastName = 'Doe'
|
||||||
|
form.addContact()
|
||||||
|
contactAt(form, 1).email = 'invalide'
|
||||||
|
|
||||||
|
const ok = await form.submitContacts(vi.fn())
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.contactErrors.value[0]).toBeUndefined()
|
||||||
|
expect(form.contactErrors.value[1]?.email).toBe('L\'adresse email n\'est pas valide.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useProviderForm — onglet Adresse (ERP-143)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPost.mockReset()
|
||||||
|
mockPatch.mockReset()
|
||||||
|
permState.accountingView = false
|
||||||
|
permState.accountingManage = false
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Place le formulaire en etat « prestataire cree » (onglet Adresse accessible). */
|
||||||
|
function createdForm() {
|
||||||
|
const form = useProviderForm()
|
||||||
|
form.providerId.value = 7
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remplit un bloc adresse valide (site + categorie + scalaires requis). */
|
||||||
|
function fillValidAddress(form: ProviderForm, index = 0): void {
|
||||||
|
const a = addressAt(form, index)
|
||||||
|
a.siteIris = [SITE_86]
|
||||||
|
a.categoryIris = [CAT_MAINT]
|
||||||
|
a.postalCode = '86100'
|
||||||
|
a.city = 'Châtellerault'
|
||||||
|
a.street = '1 rue du Test'
|
||||||
|
}
|
||||||
|
|
||||||
|
it('RG-3.05 : « + Nouvelle adresse » desactive tant que site + categorie manquent', () => {
|
||||||
|
const form = createdForm()
|
||||||
|
expect(form.canAddAddress.value).toBe(false)
|
||||||
|
|
||||||
|
// no-op tant que l'adresse n'est pas valide.
|
||||||
|
form.addAddress()
|
||||||
|
expect(form.addresses.value).toHaveLength(1)
|
||||||
|
|
||||||
|
addressAt(form).siteIris = [SITE_86]
|
||||||
|
expect(form.canAddAddress.value).toBe(false) // categorie manquante
|
||||||
|
addressAt(form).categoryIris = [CAT_MAINT]
|
||||||
|
expect(form.canAddAddress.value).toBe(true)
|
||||||
|
form.addAddress()
|
||||||
|
expect(form.addresses.value).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removeAddress retire le bloc et son erreur de ligne', () => {
|
||||||
|
const form = createdForm()
|
||||||
|
fillValidAddress(form)
|
||||||
|
form.addAddress()
|
||||||
|
form.addressErrors.value = [{}, { city: 'x' }]
|
||||||
|
|
||||||
|
form.removeAddress(1)
|
||||||
|
expect(form.addresses.value).toHaveLength(1)
|
||||||
|
expect(form.addressErrors.value).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submitAddresses : POST des nouvelles, capture l\'id, finalise l\'onglet', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({ id: 88 })
|
||||||
|
const form = createdForm()
|
||||||
|
fillValidAddress(form)
|
||||||
|
|
||||||
|
const ok = await form.submitAddresses(vi.fn())
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||||
|
expect(url).toBe('/providers/7/addresses')
|
||||||
|
expect(body).toMatchObject({ sites: [SITE_86], categories: [CAT_MAINT], city: 'Châtellerault' })
|
||||||
|
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||||
|
expect(addressAt(form).id).toBe(88)
|
||||||
|
expect(form.isValidated('address')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submitAddresses : PATCH des adresses existantes sur /provider_addresses/{id}', async () => {
|
||||||
|
mockPatch.mockResolvedValueOnce({})
|
||||||
|
const form = createdForm()
|
||||||
|
fillValidAddress(form)
|
||||||
|
addressAt(form).id = 88
|
||||||
|
|
||||||
|
await form.submitAddresses(vi.fn())
|
||||||
|
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/provider_addresses/88', expect.objectContaining({ sites: [SITE_86] }), { toast: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe les erreurs 422 PAR LIGNE et ne finalise pas l\'onglet', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
_data: { violations: [{ propertyPath: 'city', message: 'La ville est obligatoire.' }] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const form = createdForm()
|
||||||
|
fillValidAddress(form)
|
||||||
|
|
||||||
|
const ok = await form.submitAddresses(vi.fn())
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire.')
|
||||||
|
expect(form.isValidated('address')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useProviderForm — onglet Comptabilite (ERP-144)', () => {
|
||||||
|
const TVA = '/api/tva_modes/1'
|
||||||
|
const DELAY = '/api/payment_delays/1'
|
||||||
|
const TYPE = '/api/payment_types/3'
|
||||||
|
const BANK = '/api/banks/2'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPost.mockReset()
|
||||||
|
mockPatch.mockReset()
|
||||||
|
permState.accountingView = true
|
||||||
|
permState.accountingManage = true
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Prestataire cree, onglet Comptabilite editable (view + manage). */
|
||||||
|
function createdForm() {
|
||||||
|
const form = useProviderForm()
|
||||||
|
form.providerId.value = 7
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remplit les scalaires comptables communs. */
|
||||||
|
function fillScalars(form: ProviderForm): void {
|
||||||
|
form.accounting.siren = '123456789'
|
||||||
|
form.accounting.accountNumber = '4010'
|
||||||
|
form.accounting.tvaModeIri = TVA
|
||||||
|
form.accounting.nTva = 'FR123'
|
||||||
|
form.accounting.paymentDelayIri = DELAY
|
||||||
|
form.accounting.paymentTypeIri = TYPE
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lecture seule sans accounting.manage (Compta consultation / autres roles)', () => {
|
||||||
|
permState.accountingManage = false
|
||||||
|
const form = createdForm()
|
||||||
|
expect(form.accountingReadonly.value).toBe(true)
|
||||||
|
|
||||||
|
permState.accountingManage = true
|
||||||
|
const form2 = createdForm()
|
||||||
|
expect(form2.accountingReadonly.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('RG-3.07 : setPaymentType(VIREMENT) garde la banque ; un autre type la vide', () => {
|
||||||
|
const form = createdForm()
|
||||||
|
form.accounting.bankIri = BANK
|
||||||
|
|
||||||
|
// Type VIREMENT -> banque requise, conservee.
|
||||||
|
form.setPaymentType(TYPE, true, false)
|
||||||
|
expect(form.accounting.bankIri).toBe(BANK)
|
||||||
|
|
||||||
|
// Type non-VIREMENT -> banque videe (sans objet).
|
||||||
|
form.setPaymentType(TYPE, false, false)
|
||||||
|
expect(form.accounting.bankIri).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('RG-3.08 : setPaymentType(LCR) garantit au moins un bloc RIB', () => {
|
||||||
|
const form = createdForm()
|
||||||
|
expect(form.ribs.value).toHaveLength(0)
|
||||||
|
|
||||||
|
form.setPaymentType(TYPE, false, true)
|
||||||
|
expect(form.ribs.value).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('« + RIB » desactive tant que le dernier RIB est incomplet (RG-3.08)', () => {
|
||||||
|
const form = createdForm()
|
||||||
|
form.setPaymentType(TYPE, false, true)
|
||||||
|
expect(form.canAddRib.value).toBe(false)
|
||||||
|
|
||||||
|
const rib = form.ribs.value[0]
|
||||||
|
if (rib) {
|
||||||
|
rib.label = 'Compte'
|
||||||
|
rib.bic = 'BNPAFRPP'
|
||||||
|
rib.iban = 'FR76...'
|
||||||
|
}
|
||||||
|
expect(form.canAddRib.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('VIREMENT : PATCH des scalaires avec banque, aucun appel RIB', async () => {
|
||||||
|
mockPatch.mockResolvedValueOnce({})
|
||||||
|
const form = createdForm()
|
||||||
|
fillScalars(form)
|
||||||
|
form.accounting.bankIri = BANK
|
||||||
|
|
||||||
|
const ok = await form.submitAccounting(true, false, vi.fn())
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/providers/7',
|
||||||
|
expect.objectContaining({ paymentType: TYPE, bank: BANK, siren: '123456789' }),
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
expect(form.isValidated('accounting')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hors VIREMENT : la banque part a null dans le payload (RG-3.07)', async () => {
|
||||||
|
mockPatch.mockResolvedValueOnce({})
|
||||||
|
const form = createdForm()
|
||||||
|
fillScalars(form)
|
||||||
|
form.accounting.bankIri = BANK // residu : doit etre ignore (isBankRequired=false)
|
||||||
|
|
||||||
|
await form.submitAccounting(false, false, vi.fn())
|
||||||
|
|
||||||
|
const body = mockPatch.mock.calls[0]?.[1] as Record<string, unknown>
|
||||||
|
expect(body.bank).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('LCR : POST des RIB AVANT le PATCH des scalaires (ordre RG-3.08)', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({ id: 50 })
|
||||||
|
mockPatch.mockResolvedValueOnce({})
|
||||||
|
const form = createdForm()
|
||||||
|
fillScalars(form)
|
||||||
|
form.setPaymentType(TYPE, false, true)
|
||||||
|
const rib = form.ribs.value[0]
|
||||||
|
if (rib) {
|
||||||
|
rib.label = 'Compte'
|
||||||
|
rib.bic = 'BNPAFRPP'
|
||||||
|
rib.iban = 'FR76...'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await form.submitAccounting(false, true, vi.fn())
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/providers/7/ribs',
|
||||||
|
expect.objectContaining({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }),
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
expect(form.ribs.value[0]?.id).toBe(50)
|
||||||
|
// Le PATCH des scalaires intervient APRES la creation du RIB.
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/providers/7', expect.any(Object), { toast: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('422 sur les scalaires (bank) : mapping inline, onglet non finalise', async () => {
|
||||||
|
mockPatch.mockRejectedValueOnce({
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
_data: { violations: [{ propertyPath: 'bank', message: 'La banque est obligatoire pour un virement.' }] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const form = createdForm()
|
||||||
|
fillScalars(form)
|
||||||
|
|
||||||
|
const ok = await form.submitAccounting(true, false, vi.fn())
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.accountingErrors.errors.bank).toBe('La banque est obligatoire pour un virement.')
|
||||||
|
expect(form.isValidated('accounting')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('LCR : 422 RIB par ligne -> pas de PATCH des scalaires', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
_data: { violations: [{ propertyPath: 'iban', message: 'L\'IBAN est obligatoire.' }] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const form = createdForm()
|
||||||
|
fillScalars(form)
|
||||||
|
form.setPaymentType(TYPE, false, true)
|
||||||
|
const rib = form.ribs.value[0]
|
||||||
|
if (rib) {
|
||||||
|
rib.label = 'Compte'
|
||||||
|
rib.bic = 'BNPAFRPP'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await form.submitAccounting(false, true, vi.fn())
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.ribErrors.value[0]?.iban).toBe('L\'IBAN est obligatoire.')
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useProviderForm — modification (ERP-145)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPost.mockReset()
|
||||||
|
mockPatch.mockReset()
|
||||||
|
permState.accountingView = false
|
||||||
|
permState.accountingManage = false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('editMode : completeTab ne verrouille pas et ne bascule pas d\'onglet', () => {
|
||||||
|
const form = useProviderForm()
|
||||||
|
form.editMode.value = true
|
||||||
|
form.activeTab.value = 'contact'
|
||||||
|
|
||||||
|
expect(form.completeTab('contact')).toBe(false)
|
||||||
|
expect(form.isValidated('contact')).toBe(false)
|
||||||
|
expect(form.activeTab.value).toBe('contact')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateMain : PATCH /providers/{id} sur le groupe principal (pas de POST)', async () => {
|
||||||
|
mockPatch.mockResolvedValueOnce({ id: 7, companyName: 'MAINTENANCE PRO' })
|
||||||
|
const form = useProviderForm()
|
||||||
|
form.providerId.value = 7
|
||||||
|
form.main.companyName = 'Maintenance Pro'
|
||||||
|
form.main.categoryIris = [CAT_MAINT]
|
||||||
|
form.main.siteIris = [SITE_86]
|
||||||
|
|
||||||
|
const ok = await form.updateMain()
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/providers/7',
|
||||||
|
{ companyName: 'Maintenance Pro', categories: [CAT_MAINT], sites: [SITE_86] },
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
// Reaffiche le nom normalise renvoye par le serveur.
|
||||||
|
expect(form.main.companyName).toBe('MAINTENANCE PRO')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateMain : RG-3.03 front -> bloque le PATCH sans site', async () => {
|
||||||
|
const form = useProviderForm()
|
||||||
|
form.providerId.value = 7
|
||||||
|
form.main.companyName = 'X'
|
||||||
|
form.main.categoryIris = [CAT_MAINT]
|
||||||
|
|
||||||
|
const ok = await form.updateMain()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(mockPatch).not.toHaveBeenCalled()
|
||||||
|
expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updateMain : 409 doublon -> erreur inline sur companyName', async () => {
|
||||||
|
mockPatch.mockRejectedValueOnce({ response: { status: 409 } })
|
||||||
|
const form = useProviderForm()
|
||||||
|
form.providerId.value = 7
|
||||||
|
form.main.companyName = 'Doublon'
|
||||||
|
form.main.categoryIris = [CAT_MAINT]
|
||||||
|
form.main.siteIris = [SITE_86]
|
||||||
|
|
||||||
|
const ok = await form.updateMain()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import type { ProviderDetail } from '~/modules/technique/utils/forms/providerDetail'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chargement et actions d'archivage d'un prestataire unique (ecrans Consultation /
|
||||||
|
* Modification, ERP-145). Miroir de `useSupplier` (M2). Lit le detail embarque via
|
||||||
|
* `GET /api/providers/{id}` (contacts / adresses + leurs sous-collections / ribs
|
||||||
|
* sous `provider:item:read` / `provider:read:accounting`) — une SEULE requete
|
||||||
|
* peuple les deux ecrans (embed borne, pas de N+1).
|
||||||
|
*
|
||||||
|
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload Hydra
|
||||||
|
* complet (avec les `@id` des relations embarquees, indispensables au pre-remplissage).
|
||||||
|
*
|
||||||
|
* Etat 100 % local a l'instance (refs). Les erreurs d'archivage / restauration
|
||||||
|
* (notamment le 409 d'homonyme actif a la restauration) sont PROPAGEES a l'appelant,
|
||||||
|
* qui decide du toast a afficher.
|
||||||
|
*/
|
||||||
|
export function useProvider(id: number | string) {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const provider = ref<ProviderDetail | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(false)
|
||||||
|
|
||||||
|
/** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
|
||||||
|
function fetchDetail(): Promise<ProviderDetail> {
|
||||||
|
return api.get<ProviderDetail>(
|
||||||
|
`/providers/${id}`,
|
||||||
|
{},
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Charge le detail du prestataire. En cas d'echec : `error = true`, `provider = null`. */
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = false
|
||||||
|
try {
|
||||||
|
provider.value = await fetchDetail()
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
error.value = true
|
||||||
|
provider.value = null
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bascule l'archivage (PATCH `isArchived` SEUL — groupe provider:write:archive ;
|
||||||
|
* tout autre champ => 422), puis RECHARGE le detail complet : la reponse du PATCH
|
||||||
|
* ne porte que `provider:read` (ni l'embed des sous-collections ni les libelles
|
||||||
|
* comptables), un simple merge laisserait l'affichage incoherent. Toute erreur est
|
||||||
|
* propagee a l'appelant AVANT le rechargement.
|
||||||
|
*/
|
||||||
|
async function setArchived(isArchived: boolean): Promise<void> {
|
||||||
|
await api.patch(`/providers/${id}`, { isArchived }, { toast: false })
|
||||||
|
provider.value = await fetchDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
load,
|
||||||
|
archive: () => setArchived(true),
|
||||||
|
restore: () => setArchived(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge les referentiels (listes courtes) alimentant les selects du formulaire
|
||||||
|
* principal de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) :
|
||||||
|
* categories (type PRESTATAIRE) et sites (86 / 17 / 82).
|
||||||
|
*
|
||||||
|
* Miroir reduit de `useSupplierReferentials` (M2) : a ce stade (formulaire
|
||||||
|
* principal) seuls categories + sites sont necessaires. Les referentiels
|
||||||
|
* comptables (modes de TVA, delais/types de reglement, banques) seront charges
|
||||||
|
* par l'onglet Comptabilite (ERP-144).
|
||||||
|
*
|
||||||
|
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
|
||||||
|
* `?pagination=false` (referentiels de quelques entrees), avec l'en-tete
|
||||||
|
* `Accept: application/ld+json` impose par API Platform 4 pour obtenir l'enveloppe
|
||||||
|
* Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`), renvoyee telle
|
||||||
|
* quelle dans le payload POST (relations M2M).
|
||||||
|
*
|
||||||
|
* Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole ; un
|
||||||
|
* echec (permission manquante, reseau) laisse simplement la liste vide.
|
||||||
|
*
|
||||||
|
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
|
||||||
|
export interface RefOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Option de type de reglement enrichie de son code stable (RG-3.07 / RG-3.08). */
|
||||||
|
export interface PaymentTypeOption extends RefOption {
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HydraMember {
|
||||||
|
'@id': string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReferentialMember extends HydraMember {
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryMember extends HydraMember {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteMember extends HydraMember {
|
||||||
|
name: string
|
||||||
|
postalCode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CountryMember extends HydraMember {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||||
|
|
||||||
|
export function useProviderReferentials() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const categories = ref<RefOption[]>([])
|
||||||
|
const sites = ref<RefOption[]>([])
|
||||||
|
const countries = ref<RefOption[]>([])
|
||||||
|
// Referentiels comptables (charges a la demande via loadAccounting).
|
||||||
|
const tvaModes = ref<RefOption[]>([])
|
||||||
|
const paymentDelays = ref<RefOption[]>([])
|
||||||
|
const paymentTypes = ref<PaymentTypeOption[]>([])
|
||||||
|
const banks = ref<RefOption[]>([])
|
||||||
|
|
||||||
|
/** Recupere une collection complete (pagination desactivee) en Hydra. */
|
||||||
|
async function fetchAll<T extends HydraMember>(
|
||||||
|
url: string,
|
||||||
|
query: Record<string, string | string[]> = {},
|
||||||
|
): Promise<T[]> {
|
||||||
|
const res = await api.get<{ member?: T[] }>(
|
||||||
|
url,
|
||||||
|
{ pagination: 'false', ...query },
|
||||||
|
{ headers: LD_JSON_HEADERS, toast: false },
|
||||||
|
)
|
||||||
|
return res.member ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Charge en parallele les referentiels du formulaire principal (categories + sites). */
|
||||||
|
async function loadMain(): Promise<void> {
|
||||||
|
await Promise.allSettled([
|
||||||
|
// RG-3.09 : un prestataire ne porte que des categories de type
|
||||||
|
// PRESTATAIRE -> filtre cote API. Libelle affiche = `name`.
|
||||||
|
fetchAll<CategoryMember>('/categories', { typeCode: 'PRESTATAIRE' })
|
||||||
|
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name })) }),
|
||||||
|
// Sites (RG-3.03) : libelle = numero de departement (2 premiers chiffres
|
||||||
|
// du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ».
|
||||||
|
fetchAll<SiteMember>('/sites')
|
||||||
|
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
|
||||||
|
// Pays (ERP-116) : la valeur d'option est le NOM du pays (l'adresse stocke
|
||||||
|
// `country` en chaine libre, « France »...). value === label. Aligne sur
|
||||||
|
// les ecrans client/fournisseur. Sert le select Pays de l'onglet Adresse.
|
||||||
|
fetchAll<CountryMember>('/countries')
|
||||||
|
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge les referentiels comptables (onglet Comptabilite, ERP-144). Appele
|
||||||
|
* uniquement quand l'utilisateur peut voir l'onglet (accounting.view). Resilient
|
||||||
|
* (allSettled) : un referentiel en echec reste vide.
|
||||||
|
*/
|
||||||
|
async function loadAccounting(): Promise<void> {
|
||||||
|
await Promise.allSettled([
|
||||||
|
fetchAll<ReferentialMember>('/tva_modes')
|
||||||
|
.then((list) => { tvaModes.value = list.map(t => ({ value: t['@id'], label: t.label })) }),
|
||||||
|
fetchAll<ReferentialMember>('/payment_delays')
|
||||||
|
.then((list) => { paymentDelays.value = list.map(d => ({ value: d['@id'], label: d.label })) }),
|
||||||
|
// Le code stable du type sert les RG-3.07 (VIREMENT) / RG-3.08 (LCR).
|
||||||
|
fetchAll<ReferentialMember>('/payment_types')
|
||||||
|
.then((list) => { paymentTypes.value = list.map(p => ({ value: p['@id'], label: p.label, code: p.code })) }),
|
||||||
|
fetchAll<ReferentialMember>('/banks')
|
||||||
|
.then((list) => { banks.value = list.map(b => ({ value: b['@id'], label: b.label })) }),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories,
|
||||||
|
sites,
|
||||||
|
countries,
|
||||||
|
tvaModes,
|
||||||
|
paymentDelays,
|
||||||
|
paymentTypes,
|
||||||
|
banks,
|
||||||
|
loadMain,
|
||||||
|
loadAccounting,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tete : retour repertoire + nom du prestataire + actions. -->
|
||||||
|
<div class="flex items-center gap-3 pt-11">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
v-bind="{ ariaLabel: t('technique.providers.consultation.back') }"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
|
||||||
|
|
||||||
|
<div class="ml-auto flex items-center gap-12">
|
||||||
|
<MalioButton
|
||||||
|
v-if="canEdit"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:pencil-outline"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('technique.providers.action.edit')"
|
||||||
|
@click="goEdit"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
v-if="showArchive"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:archive-arrow-down-outline"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('technique.providers.action.archive')"
|
||||||
|
@click="askToggleArchive"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
v-if="showRestore"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:archive-arrow-up-outline"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('technique.providers.action.restore')"
|
||||||
|
@click="askToggleArchive"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Etats de chargement / introuvable. -->
|
||||||
|
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('technique.providers.consultation.loading') }}</p>
|
||||||
|
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('technique.providers.consultation.notFound') }}</p>
|
||||||
|
|
||||||
|
<template v-else-if="provider">
|
||||||
|
<!-- ── Bloc principal (lecture seule) ─────────────────────────────── -->
|
||||||
|
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="provider.companyName"
|
||||||
|
:label="t('technique.providers.form.main.companyName')"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="mainCategoryIris"
|
||||||
|
:options="mainCategoryOptions"
|
||||||
|
:label="t('technique.providers.form.main.categories')"
|
||||||
|
:display-tag="true"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="mainSiteIris"
|
||||||
|
:options="mainSiteOptions"
|
||||||
|
:label="t('technique.providers.form.main.sites')"
|
||||||
|
:display-tag="true"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Onglets (navigation libre, tout en lecture seule) ──────────── -->
|
||||||
|
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||||
|
<!-- Onglet Contacts -->
|
||||||
|
<template #contacts>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<ProviderContactBlock
|
||||||
|
v-for="(contact, index) in contacts"
|
||||||
|
:key="index"
|
||||||
|
:model-value="contact"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Adresse -->
|
||||||
|
<template #address>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<ProviderAddressBlock
|
||||||
|
v-for="(view, index) in addressViews"
|
||||||
|
:key="index"
|
||||||
|
:model-value="view.draft"
|
||||||
|
:category-options="view.categoryOptions"
|
||||||
|
:site-options="view.siteOptions"
|
||||||
|
:contact-options="contactOptions"
|
||||||
|
:country-options="countryOptionsFor(view.draft.country)"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglets placeholder « A venir » (comme les autres modules). -->
|
||||||
|
<template #reports><ComingSoonPlaceholder /></template>
|
||||||
|
<template #exchanges><ComingSoonPlaceholder /></template>
|
||||||
|
|
||||||
|
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
|
||||||
|
<template v-if="canAccountingView" #accounting>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||||
|
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" readonly />
|
||||||
|
<MalioInputText :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" readonly />
|
||||||
|
<MalioSelect :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" readonly empty-option-label="" />
|
||||||
|
<MalioInputText :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" readonly />
|
||||||
|
<MalioSelect :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" readonly empty-option-label="" />
|
||||||
|
<MalioSelect :model-value="accounting.paymentTypeIri" :options="paymentTypeOptions" :label="t('technique.providers.form.accounting.paymentType')" readonly empty-option-label="" />
|
||||||
|
<MalioSelect v-if="isBankRequired" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" readonly empty-option-label="" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocs RIB (uniquement si type de reglement = LCR). -->
|
||||||
|
<div
|
||||||
|
v-for="(rib, index) in visibleRibs"
|
||||||
|
:key="index"
|
||||||
|
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" readonly />
|
||||||
|
<MalioInputText :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" readonly />
|
||||||
|
<MalioInputText :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" readonly />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</MalioTabList>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Modal de confirmation archivage / restauration. -->
|
||||||
|
<MalioModal v-model="confirmArchive.open" modal-class="max-w-md">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">{{ confirmArchive.title }}</h2>
|
||||||
|
</template>
|
||||||
|
<p>{{ confirmArchive.message }}</p>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="t('technique.providers.form.confirmDelete.cancel')"
|
||||||
|
@click="confirmArchive.open = false"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="danger"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="confirmArchive.confirmLabel"
|
||||||
|
@click="runToggleArchive"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useProvider } from '~/modules/technique/composables/useProvider'
|
||||||
|
import {
|
||||||
|
canEditProvider,
|
||||||
|
categoryOptionsOf,
|
||||||
|
contactOptionsOf,
|
||||||
|
irisOf,
|
||||||
|
mapAccountingDraft,
|
||||||
|
mapAddressToDraft,
|
||||||
|
mapContactToDraft,
|
||||||
|
mapRibToDraft,
|
||||||
|
paymentTypeCodeOf,
|
||||||
|
referentialOptionOf,
|
||||||
|
showArchiveAction,
|
||||||
|
showRestoreAction,
|
||||||
|
siteOptionsOf,
|
||||||
|
} from '~/modules/technique/utils/forms/providerDetail'
|
||||||
|
import { isBankRequiredForPaymentType, isRibRequiredForPaymentType } from '~/modules/technique/utils/forms/providerAccounting'
|
||||||
|
import { emptyProviderAddress, emptyProviderContact } from '~/modules/technique/types/providerForm'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const { can, canAny } = usePermissions()
|
||||||
|
|
||||||
|
const providerId = route.params.id as string
|
||||||
|
const { provider, loading, error, load, archive, restore } = useProvider(providerId)
|
||||||
|
|
||||||
|
const canAccountingView = computed(() => can('technique.providers.accounting.view'))
|
||||||
|
const canEdit = computed(() => canEditProvider(canAny))
|
||||||
|
const isArchived = computed(() => provider.value?.isArchived ?? false)
|
||||||
|
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
|
||||||
|
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
|
||||||
|
|
||||||
|
const headerTitle = computed(() => provider.value?.companyName || t('technique.providers.consultation.title'))
|
||||||
|
useHead({ title: t('technique.providers.consultation.title') })
|
||||||
|
|
||||||
|
// ── Onglets (ordre spec : Contacts · Adresse · Rapports · Échanges · Comptabilité) ──
|
||||||
|
const activeTab = ref('contacts')
|
||||||
|
const TAB_ICONS: Record<string, string> = {
|
||||||
|
contacts: 'mdi:account-box-plus-outline',
|
||||||
|
address: 'mdi:map-marker-outline',
|
||||||
|
reports: 'mdi:file-chart-outline',
|
||||||
|
exchanges: 'mdi:swap-horizontal',
|
||||||
|
accounting: 'mdi:bank-circle-outline',
|
||||||
|
}
|
||||||
|
const tabs = computed(() => {
|
||||||
|
const keys = ['contacts', 'address', 'reports', 'exchanges']
|
||||||
|
if (canAccountingView.value) keys.push('accounting')
|
||||||
|
return keys.map(key => ({ key, label: t(`technique.providers.tab.${key}`), icon: TAB_ICONS[key] }))
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Donnees mappees depuis la SEULE reponse detail ─────────────────────────────
|
||||||
|
const mainCategoryIris = computed(() => irisOf(provider.value?.categories))
|
||||||
|
const mainSiteIris = computed(() => irisOf(provider.value?.sites))
|
||||||
|
const mainCategoryOptions = computed(() => categoryOptionsOf(provider.value?.categories))
|
||||||
|
const mainSiteOptions = computed(() => siteOptionsOf(provider.value?.sites))
|
||||||
|
|
||||||
|
// Au moins un bloc affiche meme sans donnee (bloc vide en lecture seule, comme
|
||||||
|
// l'onglet Comptabilite et les autres modules — pas de message « Aucun … »).
|
||||||
|
const contacts = computed(() => {
|
||||||
|
const list = (provider.value?.contacts ?? []).map(mapContactToDraft)
|
||||||
|
return list.length > 0 ? list : [emptyProviderContact()]
|
||||||
|
})
|
||||||
|
// Contacts rattachables (pour resoudre les libelles des contacts lies aux adresses).
|
||||||
|
const contactOptions = computed(() => contactOptionsOf(provider.value?.contacts))
|
||||||
|
|
||||||
|
// Vue par adresse : brouillon + options propres a l'adresse (sites/categories embarques).
|
||||||
|
const addressViews = computed(() => {
|
||||||
|
const views = (provider.value?.addresses ?? []).map(address => ({
|
||||||
|
draft: mapAddressToDraft(address),
|
||||||
|
siteOptions: siteOptionsOf(address.sites),
|
||||||
|
categoryOptions: categoryOptionsOf(address.categories),
|
||||||
|
}))
|
||||||
|
return views.length > 0
|
||||||
|
? views
|
||||||
|
: [{ draft: emptyProviderAddress(), siteOptions: [], categoryOptions: [] }]
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Pays : une seule option (la valeur courante), suffisant pour l'affichage readonly. */
|
||||||
|
function countryOptionsFor(country: string): { value: string, label: string }[] {
|
||||||
|
return country ? [{ value: country, label: country }] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Comptabilite (presente uniquement si accounting.view) ──────────────────────
|
||||||
|
const accounting = computed(() => mapAccountingDraft(provider.value ?? { id: 0, '@id': '' }))
|
||||||
|
const paymentTypeCode = computed(() => paymentTypeCodeOf(provider.value?.paymentType))
|
||||||
|
const isBankRequired = computed(() => isBankRequiredForPaymentType(paymentTypeCode.value))
|
||||||
|
const isRibRequired = computed(() => isRibRequiredForPaymentType(paymentTypeCode.value))
|
||||||
|
const visibleRibs = computed(() => isRibRequired.value ? (provider.value?.ribs ?? []).map(mapRibToDraft) : [])
|
||||||
|
|
||||||
|
// Options « une entree » construites depuis l'embed (libelles role-independants).
|
||||||
|
const tvaModeOptions = computed(() => referentialOptionOf(provider.value?.tvaMode))
|
||||||
|
const paymentDelayOptions = computed(() => referentialOptionOf(provider.value?.paymentDelay))
|
||||||
|
const paymentTypeOptions = computed(() => referentialOptionOf(provider.value?.paymentType))
|
||||||
|
const bankOptions = computed(() => referentialOptionOf(provider.value?.bank))
|
||||||
|
|
||||||
|
// ── Navigation / actions ───────────────────────────────────────────────────────
|
||||||
|
function goBack(): void {
|
||||||
|
router.push('/providers')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goEdit(): void {
|
||||||
|
router.push(`/providers/${providerId}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Archivage / restauration ───────────────────────────────────────────────────
|
||||||
|
const confirmArchive = reactive({
|
||||||
|
open: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
confirmLabel: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function askToggleArchive(): void {
|
||||||
|
const archiving = !isArchived.value
|
||||||
|
confirmArchive.title = archiving
|
||||||
|
? t('technique.providers.action.archive')
|
||||||
|
: t('technique.providers.action.restore')
|
||||||
|
confirmArchive.message = archiving
|
||||||
|
? t('technique.providers.consultation.confirmArchive')
|
||||||
|
: t('technique.providers.consultation.confirmRestore')
|
||||||
|
confirmArchive.confirmLabel = archiving
|
||||||
|
? t('technique.providers.action.archive')
|
||||||
|
: t('technique.providers.action.restore')
|
||||||
|
confirmArchive.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runToggleArchive(): Promise<void> {
|
||||||
|
const archiving = !isArchived.value
|
||||||
|
confirmArchive.open = false
|
||||||
|
try {
|
||||||
|
await (archiving ? archive() : restore())
|
||||||
|
toast.success({
|
||||||
|
title: archiving
|
||||||
|
? t('technique.providers.toast.archiveSuccess')
|
||||||
|
: t('technique.providers.toast.restoreSuccess'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// 409 a la restauration (homonyme actif) ou autre : toast generique.
|
||||||
|
toast.error({ title: t('technique.providers.toast.error') })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Types « brouillon » de l'ecran « Ajouter un prestataire » (M3 Technique).
|
||||||
|
*
|
||||||
|
* Miroir reduit de `types/supplierForm.ts` (M2) : le M3 n'a PAS d'onglet
|
||||||
|
* Information, et porte en plus un selecteur de site SUR le formulaire principal
|
||||||
|
* (RG-3.03 — relation directe `provider.sites`, distincte des sites d'adresse).
|
||||||
|
*
|
||||||
|
* Ces interfaces decrivent l'etat LOCAL du formulaire (refs Vue), distinct des
|
||||||
|
* DTO de l'API : la page de creation (ERP-141) et — a venir — les blocs d'onglet
|
||||||
|
* Contact / Adresse / Comptabilite (ERP-142 → 144) les partagent.
|
||||||
|
*
|
||||||
|
* Les relations M2M (categories, sites) sont portees par leurs IRI Hydra (`@id`),
|
||||||
|
* envoyees telles quelles dans le payload POST (cf. contrat back ERP-139 :
|
||||||
|
* `categories: ['/api/categories/{id}']`, `sites: ['/api/sites/{id}']`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Etat « plat » du formulaire principal (groupe `provider:write:main`). */
|
||||||
|
export interface ProviderMainDraft {
|
||||||
|
/** Nom de l'entreprise prestataire. UPPERCASE serveur (RG-3.11), unicite RG-3.10. */
|
||||||
|
companyName: string | null
|
||||||
|
/** IRI des categories rattachees (M2M, type PRESTATAIRE — RG-3.09 ; >= 1). */
|
||||||
|
categoryIris: string[]
|
||||||
|
/** IRI des sites rattaches DIRECTEMENT au prestataire (M2M `provider_site`, RG-3.03 ; >= 1). */
|
||||||
|
siteIris: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fabrique un formulaire principal vierge. */
|
||||||
|
export function emptyProviderMain(): ProviderMainDraft {
|
||||||
|
return {
|
||||||
|
companyName: null,
|
||||||
|
categoryIris: [],
|
||||||
|
siteIris: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reponse minimale du POST /providers exploitee par l'ecran de creation. */
|
||||||
|
export interface ProviderMainResponse {
|
||||||
|
id: number
|
||||||
|
/** Nom renvoye normalise (UPPERCASE) par le serveur, reaffiche en lecture seule. */
|
||||||
|
companyName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un contact du prestataire (onglet Contact, ERP-142). Miroir de
|
||||||
|
* `SupplierContactFormDraft` (M2). Tous les champs sont nullable cote ORM ; la
|
||||||
|
* validite (RG-3.04) tient a la presence d'AU MOINS un champ rempli parmi
|
||||||
|
* prenom / nom / fonction / telephone principal / email (cf. back).
|
||||||
|
*/
|
||||||
|
export interface ProviderContactFormDraft {
|
||||||
|
/** Id serveur une fois le contact cree (null tant que non persiste). */
|
||||||
|
id: number | null
|
||||||
|
/** IRI Hydra du contact cree — servira au rattachement M2M cote adresse (ERP-143). */
|
||||||
|
iri: string | null
|
||||||
|
firstName: string | null
|
||||||
|
lastName: string | null
|
||||||
|
jobTitle: string | null
|
||||||
|
phonePrimary: string | null
|
||||||
|
phoneSecondary: string | null
|
||||||
|
email: string | null
|
||||||
|
/** UI : le 2e numero a ete revele via le bouton « + » (max 2 telephones). */
|
||||||
|
hasSecondaryPhone: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fabrique un contact vierge. */
|
||||||
|
export function emptyProviderContact(): ProviderContactFormDraft {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
iri: null,
|
||||||
|
firstName: null,
|
||||||
|
lastName: null,
|
||||||
|
jobTitle: null,
|
||||||
|
phonePrimary: null,
|
||||||
|
phoneSecondary: null,
|
||||||
|
email: null,
|
||||||
|
hasSecondaryPhone: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reponse du POST /providers/{id}/contacts (groupe provider:item:read + IRI Hydra). */
|
||||||
|
export interface ProviderContactResponse {
|
||||||
|
'@id'?: string
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une adresse du prestataire (onglet Adresse, ERP-143). Version SIMPLIFIEE de
|
||||||
|
* `SupplierAddressFormDraft` (M2) : PAS de type d'adresse (Prospect/Depart/Rendu),
|
||||||
|
* PAS de bennes, PAS de prestation de triage. Champs postaux + M2M sites /
|
||||||
|
* categories / contacts (par IRI).
|
||||||
|
*/
|
||||||
|
export interface ProviderAddressFormDraft {
|
||||||
|
/** Id serveur une fois l'adresse creee (null tant que non persistee). */
|
||||||
|
id: number | null
|
||||||
|
/** Pays (chaine libre, defaut « France »). */
|
||||||
|
country: string
|
||||||
|
postalCode: string | null
|
||||||
|
city: string | null
|
||||||
|
street: string | null
|
||||||
|
streetComplement: string | null
|
||||||
|
/** IRI des categories rattachees (type PRESTATAIRE, RG-3.09 ; >= 1). */
|
||||||
|
categoryIris: string[]
|
||||||
|
/** IRI des sites rattaches a l'adresse (M2M `provider_address_site`, RG-3.05 ; >= 1). */
|
||||||
|
siteIris: string[]
|
||||||
|
/** IRI des contacts rattaches (= blocs Contact deja persistes de l'onglet Contact). */
|
||||||
|
contactIris: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fabrique une adresse vierge (France presaisi). */
|
||||||
|
export function emptyProviderAddress(): ProviderAddressFormDraft {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
country: 'France',
|
||||||
|
postalCode: null,
|
||||||
|
city: null,
|
||||||
|
street: null,
|
||||||
|
streetComplement: null,
|
||||||
|
categoryIris: [],
|
||||||
|
siteIris: [],
|
||||||
|
contactIris: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reponse du POST /providers/{id}/addresses (id suffisant pour le suivi cote front). */
|
||||||
|
export interface ProviderAddressResponse {
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Etat « plat » de l'onglet Comptabilite (groupe `provider:write:accounting`).
|
||||||
|
* Relations (TVA / delai / type de reglement / banque) portees par leur IRI.
|
||||||
|
*/
|
||||||
|
export interface ProviderAccountingDraft {
|
||||||
|
siren: string | null
|
||||||
|
accountNumber: string | null
|
||||||
|
tvaModeIri: string | null
|
||||||
|
nTva: string | null
|
||||||
|
paymentDelayIri: string | null
|
||||||
|
paymentTypeIri: string | null
|
||||||
|
/** Banque : requise et envoyee uniquement si Type de reglement = VIREMENT (RG-3.07). */
|
||||||
|
bankIri: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fabrique un onglet Comptabilite vierge. */
|
||||||
|
export function emptyProviderAccounting(): ProviderAccountingDraft {
|
||||||
|
return {
|
||||||
|
siren: null,
|
||||||
|
accountNumber: null,
|
||||||
|
tvaModeIri: null,
|
||||||
|
nTva: null,
|
||||||
|
paymentDelayIri: null,
|
||||||
|
paymentTypeIri: null,
|
||||||
|
bankIri: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un RIB du prestataire (sous-collection comptable, obligatoire si Type = LCR — RG-3.08). */
|
||||||
|
export interface ProviderRibFormDraft {
|
||||||
|
id: number | null
|
||||||
|
label: string | null
|
||||||
|
bic: string | null
|
||||||
|
iban: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fabrique un RIB vierge. */
|
||||||
|
export function emptyProviderRib(): ProviderRibFormDraft {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
label: null,
|
||||||
|
bic: null,
|
||||||
|
iban: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reponse du POST /providers/{id}/ribs (id suffisant pour le suivi cote front). */
|
||||||
|
export interface ProviderRibResponse {
|
||||||
|
id: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
buildProviderAccountingPayload,
|
||||||
|
buildProviderRibPayload,
|
||||||
|
isBankRequiredForPaymentType,
|
||||||
|
isRibBlank,
|
||||||
|
isRibComplete,
|
||||||
|
isRibRequiredForPaymentType,
|
||||||
|
} from '../providerAccounting'
|
||||||
|
import { emptyProviderAccounting, emptyProviderRib } from '~/modules/technique/types/providerForm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers purs de l'onglet Comptabilite prestataire (ERP-144) : RG inter-champs
|
||||||
|
* RG-3.07 (banque si VIREMENT) / RG-3.08 (RIB si LCR) + construction des payloads.
|
||||||
|
*/
|
||||||
|
describe('providerAccounting helpers', () => {
|
||||||
|
describe('RG-3.07 / RG-3.08 — type de reglement', () => {
|
||||||
|
it('banque requise uniquement pour VIREMENT', () => {
|
||||||
|
expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true)
|
||||||
|
expect(isBankRequiredForPaymentType('LCR')).toBe(false)
|
||||||
|
expect(isBankRequiredForPaymentType('CHEQUE')).toBe(false)
|
||||||
|
expect(isBankRequiredForPaymentType(null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('RIB requis uniquement pour LCR', () => {
|
||||||
|
expect(isRibRequiredForPaymentType('LCR')).toBe(true)
|
||||||
|
expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false)
|
||||||
|
expect(isRibRequiredForPaymentType(null)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isRibBlank / isRibComplete', () => {
|
||||||
|
it('un RIB vierge est vide et incomplet', () => {
|
||||||
|
expect(isRibBlank(emptyProviderRib())).toBe(true)
|
||||||
|
expect(isRibComplete(emptyProviderRib())).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un RIB partiel n\'est ni vide ni complet', () => {
|
||||||
|
const rib = { ...emptyProviderRib(), iban: 'FR76...' }
|
||||||
|
expect(isRibBlank(rib)).toBe(false)
|
||||||
|
expect(isRibComplete(rib)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un RIB avec libelle + BIC + IBAN est complet', () => {
|
||||||
|
const rib = { ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }
|
||||||
|
expect(isRibComplete(rib)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildProviderAccountingPayload (RG-3.07)', () => {
|
||||||
|
it('envoie la banque si requise (VIREMENT)', () => {
|
||||||
|
const payload = buildProviderAccountingPayload({
|
||||||
|
...emptyProviderAccounting(),
|
||||||
|
paymentTypeIri: '/api/payment_types/3',
|
||||||
|
bankIri: '/api/banks/2',
|
||||||
|
}, true)
|
||||||
|
expect(payload.bank).toBe('/api/banks/2')
|
||||||
|
expect(payload.paymentType).toBe('/api/payment_types/3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('force la banque a null si non requise (hors VIREMENT)', () => {
|
||||||
|
const payload = buildProviderAccountingPayload({
|
||||||
|
...emptyProviderAccounting(),
|
||||||
|
bankIri: '/api/banks/2',
|
||||||
|
}, false)
|
||||||
|
expect(payload.bank).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildProviderRibPayload', () => {
|
||||||
|
it('omet les champs requis vides (NotBlank back joue sur le champ)', () => {
|
||||||
|
const payload = buildProviderRibPayload(emptyProviderRib())
|
||||||
|
expect(payload).not.toHaveProperty('label')
|
||||||
|
expect(payload).not.toHaveProperty('bic')
|
||||||
|
expect(payload).not.toHaveProperty('iban')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('conserve les champs remplis', () => {
|
||||||
|
const payload = buildProviderRibPayload({ ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' })
|
||||||
|
expect(payload).toEqual({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
buildProviderAddressPayload,
|
||||||
|
isProviderAddressValid,
|
||||||
|
} from '../providerAddress'
|
||||||
|
import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers purs de l'onglet Adresse prestataire (ERP-143). RG-3.05 (>= 1 site) et
|
||||||
|
* construction du payload de sous-ressource (relations en IRI, requis vides omis,
|
||||||
|
* pas de type d'adresse / bennes / triage — difference M2).
|
||||||
|
*/
|
||||||
|
describe('providerAddress helpers', () => {
|
||||||
|
const SITE = '/api/sites/1'
|
||||||
|
const CAT = '/api/categories/7'
|
||||||
|
|
||||||
|
describe('isProviderAddressValid (RG-3.05 / RG-3.09)', () => {
|
||||||
|
it('false sans site', () => {
|
||||||
|
const address = { ...emptyProviderAddress(), categoryIris: [CAT] }
|
||||||
|
expect(isProviderAddressValid(address)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('false sans categorie', () => {
|
||||||
|
const address = { ...emptyProviderAddress(), siteIris: [SITE] }
|
||||||
|
expect(isProviderAddressValid(address)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('true avec au moins un site ET une categorie', () => {
|
||||||
|
const address = { ...emptyProviderAddress(), siteIris: [SITE], categoryIris: [CAT] }
|
||||||
|
expect(isProviderAddressValid(address)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildProviderAddressPayload', () => {
|
||||||
|
it('mappe les relations en IRI et n\'embarque PAS type/bennes/triage (difference M2)', () => {
|
||||||
|
const payload = buildProviderAddressPayload({
|
||||||
|
...emptyProviderAddress(),
|
||||||
|
postalCode: '86100',
|
||||||
|
city: 'Châtellerault',
|
||||||
|
street: '1 rue du Test',
|
||||||
|
siteIris: [SITE],
|
||||||
|
categoryIris: [CAT],
|
||||||
|
contactIris: ['/api/provider_contacts/9'],
|
||||||
|
})
|
||||||
|
expect(payload).toEqual({
|
||||||
|
country: 'France',
|
||||||
|
postalCode: '86100',
|
||||||
|
city: 'Châtellerault',
|
||||||
|
street: '1 rue du Test',
|
||||||
|
streetComplement: null,
|
||||||
|
categories: [CAT],
|
||||||
|
sites: [SITE],
|
||||||
|
contacts: ['/api/provider_contacts/9'],
|
||||||
|
})
|
||||||
|
expect(payload).not.toHaveProperty('addressType')
|
||||||
|
expect(payload).not.toHaveProperty('bennes')
|
||||||
|
expect(payload).not.toHaveProperty('triageProvider')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omet les scalaires requis vides (NotBlank back joue sur le champ)', () => {
|
||||||
|
const payload = buildProviderAddressPayload({
|
||||||
|
...emptyProviderAddress(),
|
||||||
|
siteIris: [SITE],
|
||||||
|
categoryIris: [CAT],
|
||||||
|
})
|
||||||
|
expect(payload).not.toHaveProperty('postalCode')
|
||||||
|
expect(payload).not.toHaveProperty('city')
|
||||||
|
expect(payload).not.toHaveProperty('street')
|
||||||
|
// streetComplement n'est PAS requis -> reste present a null.
|
||||||
|
expect(payload).toHaveProperty('streetComplement', null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
buildProviderContactPayload,
|
||||||
|
hasAtLeastOneFilledContact,
|
||||||
|
isProviderContactBlank,
|
||||||
|
isProviderContactNamed,
|
||||||
|
} from '../providerContact'
|
||||||
|
import { emptyProviderContact } from '~/modules/technique/types/providerForm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers purs de l'onglet Contact prestataire (ERP-142). On verifie la
|
||||||
|
* definition de « bloc vide » (RG-3.04, alignee sur le back) et la construction
|
||||||
|
* du payload de sous-ressource.
|
||||||
|
*/
|
||||||
|
describe('providerContact helpers', () => {
|
||||||
|
describe('isProviderContactBlank (RG-3.04)', () => {
|
||||||
|
it('un bloc vierge est vide', () => {
|
||||||
|
expect(isProviderContactBlank(emptyProviderContact())).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un seul champ rempli parmi nom/prenom/fonction/tel/email suffit a le rendre non vide', () => {
|
||||||
|
for (const field of ['firstName', 'lastName', 'jobTitle', 'phonePrimary', 'email'] as const) {
|
||||||
|
const contact = { ...emptyProviderContact(), [field]: 'x' }
|
||||||
|
expect(isProviderContactBlank(contact)).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignore les espaces (trim) — un champ blanc ne compte pas', () => {
|
||||||
|
expect(isProviderContactBlank({ ...emptyProviderContact(), lastName: ' ' })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un 2e telephone seul NE suffit PAS (exclu, comme le back)', () => {
|
||||||
|
const contact = { ...emptyProviderContact(), hasSecondaryPhone: true, phoneSecondary: '0102030405' }
|
||||||
|
expect(isProviderContactBlank(contact)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isProviderContactNamed (RG-3.04 — prenom OU nom)', () => {
|
||||||
|
it('vrai avec un prenom seul ou un nom seul', () => {
|
||||||
|
expect(isProviderContactNamed({ ...emptyProviderContact(), firstName: 'Jean' })).toBe(true)
|
||||||
|
expect(isProviderContactNamed({ ...emptyProviderContact(), lastName: 'Dupont' })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faux si seuls fonction / telephone / email sont remplis (ne suffit pas)', () => {
|
||||||
|
expect(isProviderContactNamed({ ...emptyProviderContact(), jobTitle: 'Directeur' })).toBe(false)
|
||||||
|
expect(isProviderContactNamed({ ...emptyProviderContact(), email: 'a@b.fr' })).toBe(false)
|
||||||
|
expect(isProviderContactNamed({ ...emptyProviderContact(), phonePrimary: '0102030405' })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hasAtLeastOneFilledContact (RG-3.12 — au moins un contact nomme)', () => {
|
||||||
|
it('false si aucun bloc n\'est nomme', () => {
|
||||||
|
expect(hasAtLeastOneFilledContact([emptyProviderContact(), { ...emptyProviderContact(), email: 'a@b.fr' }])).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('true des qu\'un bloc porte un nom ou prenom', () => {
|
||||||
|
expect(hasAtLeastOneFilledContact([
|
||||||
|
emptyProviderContact(),
|
||||||
|
{ ...emptyProviderContact(), lastName: 'Dupont' },
|
||||||
|
])).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildProviderContactPayload', () => {
|
||||||
|
it('mappe les champs et envoie null pour les vides', () => {
|
||||||
|
const payload = buildProviderContactPayload({ ...emptyProviderContact(), lastName: 'Doe' })
|
||||||
|
expect(payload).toEqual({
|
||||||
|
firstName: null,
|
||||||
|
lastName: 'Doe',
|
||||||
|
jobTitle: null,
|
||||||
|
phonePrimary: null,
|
||||||
|
phoneSecondary: null,
|
||||||
|
email: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'envoie le 2e telephone que si revele (max 2)', () => {
|
||||||
|
const masque = buildProviderContactPayload({
|
||||||
|
...emptyProviderContact(),
|
||||||
|
phoneSecondary: '0102030405',
|
||||||
|
hasSecondaryPhone: false,
|
||||||
|
})
|
||||||
|
expect(masque.phoneSecondary).toBeNull()
|
||||||
|
|
||||||
|
const revele = buildProviderContactPayload({
|
||||||
|
...emptyProviderContact(),
|
||||||
|
phoneSecondary: '0102030405',
|
||||||
|
hasSecondaryPhone: true,
|
||||||
|
})
|
||||||
|
expect(revele.phoneSecondary).toBe('0102030405')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
|
||||||
|
// formatPhoneFR est auto-importe dans le helper via le chemin partage ; on le mocke
|
||||||
|
// pour un rendu deterministe (la mise en forme exacte est testee ailleurs).
|
||||||
|
vi.mock('~/shared/utils/phone', () => ({
|
||||||
|
formatPhoneFR: (v: string) => `fmt(${v})`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const {
|
||||||
|
canEditProvider,
|
||||||
|
categoryOptionsOf,
|
||||||
|
contactOptionsOf,
|
||||||
|
iriOf,
|
||||||
|
irisOf,
|
||||||
|
mapAccountingDraft,
|
||||||
|
mapAddressToDraft,
|
||||||
|
mapContactToDraft,
|
||||||
|
mapRibToDraft,
|
||||||
|
paymentTypeCodeOf,
|
||||||
|
referentialOptionOf,
|
||||||
|
showArchiveAction,
|
||||||
|
showRestoreAction,
|
||||||
|
siteOptionsOf,
|
||||||
|
} = await import('../providerDetail')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers purs des ecrans Consultation / Modification (ERP-145) : mapping du
|
||||||
|
* detail embarque vers les brouillons + regles d'affichage des actions (Modifier /
|
||||||
|
* Archiver / Restaurer).
|
||||||
|
*/
|
||||||
|
describe('providerDetail helpers', () => {
|
||||||
|
describe('iriOf / irisOf', () => {
|
||||||
|
it('extrait l\'IRI d\'un objet embarque, d\'un IRI nu, ou null', () => {
|
||||||
|
expect(iriOf({ '@id': '/api/banks/2' })).toBe('/api/banks/2')
|
||||||
|
expect(iriOf('/api/banks/2')).toBe('/api/banks/2')
|
||||||
|
expect(iriOf(null)).toBeNull()
|
||||||
|
expect(iriOf(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extrait les IRI d\'une collection embarquee', () => {
|
||||||
|
expect(irisOf([{ '@id': '/api/sites/1' }, { '@id': '/api/sites/2' }])).toEqual(['/api/sites/1', '/api/sites/2'])
|
||||||
|
expect(irisOf(undefined)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mapContactToDraft', () => {
|
||||||
|
it('mappe les champs, formate les telephones et derive hasSecondaryPhone', () => {
|
||||||
|
const draft = mapContactToDraft({
|
||||||
|
'@id': '/api/provider_contacts/5',
|
||||||
|
id: 5,
|
||||||
|
firstName: 'Jean',
|
||||||
|
lastName: 'Dupont',
|
||||||
|
phonePrimary: '0102030405',
|
||||||
|
phoneSecondary: '0607080910',
|
||||||
|
email: 'jean@x.fr',
|
||||||
|
})
|
||||||
|
expect(draft).toMatchObject({
|
||||||
|
id: 5,
|
||||||
|
iri: '/api/provider_contacts/5',
|
||||||
|
firstName: 'Jean',
|
||||||
|
lastName: 'Dupont',
|
||||||
|
phonePrimary: 'fmt(0102030405)',
|
||||||
|
phoneSecondary: 'fmt(0607080910)',
|
||||||
|
email: 'jean@x.fr',
|
||||||
|
hasSecondaryPhone: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hasSecondaryPhone faux sans 2e numero', () => {
|
||||||
|
const draft = mapContactToDraft({ '@id': '/api/provider_contacts/6', id: 6, lastName: 'Doe' })
|
||||||
|
expect(draft.hasSecondaryPhone).toBe(false)
|
||||||
|
expect(draft.phoneSecondary).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mapAddressToDraft', () => {
|
||||||
|
it('extrait les IRI des sites / categories / contacts embarques', () => {
|
||||||
|
const draft = mapAddressToDraft({
|
||||||
|
'@id': '/api/provider_addresses/3',
|
||||||
|
id: 3,
|
||||||
|
country: 'France',
|
||||||
|
postalCode: '86100',
|
||||||
|
city: 'Châtellerault',
|
||||||
|
street: '1 rue du Test',
|
||||||
|
sites: [{ '@id': '/api/sites/1' }],
|
||||||
|
categories: [{ '@id': '/api/categories/7' }],
|
||||||
|
contacts: [{ '@id': '/api/provider_contacts/5' }, '/api/provider_contacts/6'],
|
||||||
|
})
|
||||||
|
expect(draft.siteIris).toEqual(['/api/sites/1'])
|
||||||
|
expect(draft.categoryIris).toEqual(['/api/categories/7'])
|
||||||
|
expect(draft.contactIris).toEqual(['/api/provider_contacts/5', '/api/provider_contacts/6'])
|
||||||
|
expect(draft.id).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mapAccountingDraft / mapRibToDraft', () => {
|
||||||
|
it('mappe les scalaires et les IRI des referentiels embarques', () => {
|
||||||
|
const draft = mapAccountingDraft({
|
||||||
|
'@id': '/api/providers/9',
|
||||||
|
id: 9,
|
||||||
|
siren: '123456789',
|
||||||
|
accountNumber: '4010',
|
||||||
|
nTva: 'FR123',
|
||||||
|
tvaMode: { '@id': '/api/tva_modes/1', label: 'TVA' },
|
||||||
|
paymentType: { '@id': '/api/payment_types/3', code: 'VIREMENT' },
|
||||||
|
bank: { '@id': '/api/banks/2' },
|
||||||
|
})
|
||||||
|
expect(draft.tvaModeIri).toBe('/api/tva_modes/1')
|
||||||
|
expect(draft.paymentTypeIri).toBe('/api/payment_types/3')
|
||||||
|
expect(draft.bankIri).toBe('/api/banks/2')
|
||||||
|
expect(draft.paymentDelayIri).toBeNull()
|
||||||
|
expect(draft.siren).toBe('123456789')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe un RIB embarque', () => {
|
||||||
|
expect(mapRibToDraft({ '@id': '/api/provider_ribs/1', id: 1, label: 'Compte', bic: 'BIC', iban: 'IBAN' }))
|
||||||
|
.toEqual({ id: 1, label: 'Compte', bic: 'BIC', iban: 'IBAN' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('options builders (libelles role-independants depuis l\'embed)', () => {
|
||||||
|
it('categoryOptionsOf / siteOptionsOf / contactOptionsOf', () => {
|
||||||
|
expect(categoryOptionsOf([{ '@id': '/api/categories/7', name: 'Maintenance', code: 'MAINT' }]))
|
||||||
|
.toEqual([{ value: '/api/categories/7', label: 'Maintenance' }])
|
||||||
|
expect(siteOptionsOf([{ '@id': '/api/sites/1', name: 'Châtellerault' }]))
|
||||||
|
.toEqual([{ value: '/api/sites/1', label: 'Châtellerault' }])
|
||||||
|
expect(contactOptionsOf([{ '@id': '/api/provider_contacts/5', id: 5, firstName: 'Jean', lastName: 'Dupont' }]))
|
||||||
|
.toEqual([{ value: '/api/provider_contacts/5', label: 'Jean Dupont' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('referentialOptionOf / paymentTypeCodeOf', () => {
|
||||||
|
expect(referentialOptionOf({ '@id': '/api/banks/2', label: 'SG' }))
|
||||||
|
.toEqual([{ value: '/api/banks/2', label: 'SG' }])
|
||||||
|
expect(referentialOptionOf(null)).toEqual([])
|
||||||
|
expect(referentialOptionOf('/api/banks/2')).toEqual([])
|
||||||
|
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/3', code: 'LCR' })).toBe('LCR')
|
||||||
|
expect(paymentTypeCodeOf(null)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('actions selon permissions', () => {
|
||||||
|
/** Fabrique un `can` qui n'autorise que les codes fournis. */
|
||||||
|
const canFor = (granted: string[]) => (code: string) => granted.includes(code)
|
||||||
|
const canAnyFor = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c))
|
||||||
|
|
||||||
|
it('« Modifier » visible avec manage OU accounting.manage (Compta inclus)', () => {
|
||||||
|
expect(canEditProvider(canAnyFor(['technique.providers.manage']))).toBe(true)
|
||||||
|
expect(canEditProvider(canAnyFor(['technique.providers.accounting.manage']))).toBe(true)
|
||||||
|
expect(canEditProvider(canAnyFor(['technique.providers.view']))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('« Archiver » visible seulement avec archive ET prestataire actif (Admin seul)', () => {
|
||||||
|
const admin = canFor(['technique.providers.archive'])
|
||||||
|
const bureau = canFor(['technique.providers.manage'])
|
||||||
|
expect(showArchiveAction(admin, false)).toBe(true)
|
||||||
|
expect(showArchiveAction(admin, true)).toBe(false) // deja archive -> Restaurer
|
||||||
|
expect(showArchiveAction(bureau, false)).toBe(false) // pas la permission archive
|
||||||
|
})
|
||||||
|
|
||||||
|
it('« Restaurer » visible seulement avec archive ET prestataire archive', () => {
|
||||||
|
const admin = canFor(['technique.providers.archive'])
|
||||||
|
expect(showRestoreAction(admin, true)).toBe(true)
|
||||||
|
expect(showRestoreAction(admin, false)).toBe(false)
|
||||||
|
expect(showRestoreAction(canFor([]), true)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Helpers purs de l'onglet Comptabilite prestataire (M3 Technique, ERP-144) —
|
||||||
|
* miroir SIMPLIFIE des regles M2, reimplemente cote module Technique (regle
|
||||||
|
* ABSOLUE n°1 : pas d'import inter-module). Portent les RG inter-champs RG-3.07
|
||||||
|
* (banque si VIREMENT) et RG-3.08 (RIB si LCR), testables sans Vue ni API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ProviderAccountingDraft,
|
||||||
|
ProviderRibFormDraft,
|
||||||
|
} from '~/modules/technique/types/providerForm'
|
||||||
|
|
||||||
|
/** Code pivot du type de reglement imposant une banque (RG-3.07). */
|
||||||
|
const PAYMENT_TYPE_VIREMENT = 'VIREMENT'
|
||||||
|
/** Code pivot du type de reglement imposant au moins un RIB (RG-3.08). */
|
||||||
|
const PAYMENT_TYPE_LCR = 'LCR'
|
||||||
|
|
||||||
|
/** Champs RIB obligatoires non nullable cote back (NotBlank) — omis si vides au POST. */
|
||||||
|
const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const
|
||||||
|
|
||||||
|
/** Vrai si une chaine porte au moins un caractere non-espace. */
|
||||||
|
function isFilled(value: string | null | undefined): boolean {
|
||||||
|
return value !== null && value !== undefined && value.trim() !== ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RG-3.07 : la banque n'est requise/visible que pour un reglement par VIREMENT. */
|
||||||
|
export function isBankRequiredForPaymentType(code: string | null | undefined): boolean {
|
||||||
|
return code === PAYMENT_TYPE_VIREMENT
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RG-3.08 : au moins un RIB n'est requis que pour un reglement par LCR. */
|
||||||
|
export function isRibRequiredForPaymentType(code: string | null | undefined): boolean {
|
||||||
|
return code === PAYMENT_TYPE_LCR
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrai si AUCUN champ du bloc RIB n'est rempli (amorce vide a ignorer au submit). */
|
||||||
|
export function isRibBlank(rib: ProviderRibFormDraft): boolean {
|
||||||
|
return ![rib.label, rib.bic, rib.iban].some(isFilled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrai si les 3 champs du RIB sont remplis (gating « + RIB »). */
|
||||||
|
export function isRibComplete(rib: ProviderRibFormDraft): boolean {
|
||||||
|
return isFilled(rib.label) && isFilled(rib.bic) && isFilled(rib.iban)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload du PATCH comptable (groupe `provider:write:accounting`). Les relations
|
||||||
|
* sont en IRI ; la banque n'est envoyee que si elle est requise (RG-3.07), sinon
|
||||||
|
* `null` (le back vide la relation hors VIREMENT).
|
||||||
|
*/
|
||||||
|
export function buildProviderAccountingPayload(
|
||||||
|
accounting: ProviderAccountingDraft,
|
||||||
|
isBankRequired: boolean,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
siren: accounting.siren || null,
|
||||||
|
accountNumber: accounting.accountNumber || null,
|
||||||
|
tvaMode: accounting.tvaModeIri,
|
||||||
|
nTva: accounting.nTva || null,
|
||||||
|
paymentDelay: accounting.paymentDelayIri,
|
||||||
|
paymentType: accounting.paymentTypeIri,
|
||||||
|
bank: isBankRequired ? accounting.bankIri : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload d'un RIB (sous-ressource, groupe `provider:write:accounting`). Les
|
||||||
|
* champs requis vides sont omis a la creation pour que la 422 NotBlank porte sur
|
||||||
|
* le champ.
|
||||||
|
*/
|
||||||
|
export function buildProviderRibPayload(rib: ProviderRibFormDraft): Record<string, unknown> {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
label: rib.label,
|
||||||
|
bic: rib.bic,
|
||||||
|
iban: rib.iban,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of RIB_REQUIRED_NON_NULLABLE_KEYS) {
|
||||||
|
const value = payload[key]
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
delete payload[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Helpers purs de l'onglet Adresse prestataire (M3 Technique, ERP-143) — miroir
|
||||||
|
* SIMPLIFIE de `supplierFormRules`/`supplierEdit` (M2), reimplemente cote module
|
||||||
|
* Technique (regle ABSOLUE n°1 : pas d'import inter-module). Testables sans Vue.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champs scalaires obligatoires non nullable cote back (NotBlank). A la creation
|
||||||
|
* (POST), on OMET du payload ceux qui sont vides pour que la 422 porte la
|
||||||
|
* violation NotBlank propre (sur le champ) plutot qu'une erreur de type.
|
||||||
|
*/
|
||||||
|
const REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-3.05 (+ RG-3.09) : une adresse est « valide » pour autoriser l'ajout d'un
|
||||||
|
* nouveau bloc des qu'elle porte au moins un site ET au moins une categorie. Les
|
||||||
|
* scalaires (CP/ville/rue) restent valides par le back (422 inline).
|
||||||
|
*/
|
||||||
|
export function isProviderAddressValid(address: ProviderAddressFormDraft): boolean {
|
||||||
|
return address.siteIris.length >= 1 && address.categoryIris.length >= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload de la sous-ressource addresses (groupe `provider:write:addresses`).
|
||||||
|
* Relations M2M en IRI. Les scalaires requis vides sont omis a la creation (cf.
|
||||||
|
* REQUIRED_NON_NULLABLE_KEYS).
|
||||||
|
*/
|
||||||
|
export function buildProviderAddressPayload(address: ProviderAddressFormDraft): Record<string, unknown> {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
country: address.country,
|
||||||
|
postalCode: address.postalCode || null,
|
||||||
|
city: address.city || null,
|
||||||
|
street: address.street || null,
|
||||||
|
streetComplement: address.streetComplement || null,
|
||||||
|
categories: [...address.categoryIris],
|
||||||
|
sites: [...address.siteIris],
|
||||||
|
contacts: [...address.contactIris],
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of REQUIRED_NON_NULLABLE_KEYS) {
|
||||||
|
const value = payload[key]
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
delete payload[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Helpers purs de l'onglet Contact prestataire (M3 Technique, ERP-142) — miroir
|
||||||
|
* reduit de `supplierFormRules.ts` / `supplierEdit.ts` (M2). Testables sans Vue
|
||||||
|
* ni API : detection de bloc vide (RG-3.04) et construction du payload de
|
||||||
|
* sous-ressource contacts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
|
||||||
|
|
||||||
|
/** Vrai si une chaine porte au moins un caractere non-espace. */
|
||||||
|
function isFilled(value: string | null | undefined): boolean {
|
||||||
|
return value !== null && value !== undefined && value.trim() !== ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-3.04 : un bloc Contact est VIDE tant qu'aucun des champs comptant pour la
|
||||||
|
* validite n'est rempli — prenom / nom / fonction / telephone principal / email.
|
||||||
|
*
|
||||||
|
* `phoneSecondary` est volontairement EXCLU : le back (CHECK
|
||||||
|
* `chk_provider_contact_name` + `ProviderContactProcessor`) ne le compte pas non
|
||||||
|
* plus, un bloc ne portant qu'un 2e numero reste invalide. Garder la meme
|
||||||
|
* definition cote front evite tout drift (un bloc « vide » front == bloc rejete
|
||||||
|
* back).
|
||||||
|
*/
|
||||||
|
export function isProviderContactBlank(contact: ProviderContactFormDraft): boolean {
|
||||||
|
return ![
|
||||||
|
contact.firstName,
|
||||||
|
contact.lastName,
|
||||||
|
contact.jobTitle,
|
||||||
|
contact.phonePrimary,
|
||||||
|
contact.email,
|
||||||
|
].some(isFilled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-3.04 : un contact est « nomme » (valide) des qu'il porte un prenom OU un nom
|
||||||
|
* — aligne sur le M1/M2. Sert le gating « + Nouveau contact » et la notion de
|
||||||
|
* contact valide (la fonction / le telephone / l'email seuls ne suffisent pas).
|
||||||
|
*/
|
||||||
|
export function isProviderContactNamed(contact: ProviderContactFormDraft): boolean {
|
||||||
|
return isFilled(contact.firstName) || isFilled(contact.lastName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-3.12 : l'onglet Contact ne peut etre finalise que s'il reste au moins un
|
||||||
|
* contact nomme (prenom ou nom).
|
||||||
|
*/
|
||||||
|
export function hasAtLeastOneFilledContact(contacts: ProviderContactFormDraft[]): boolean {
|
||||||
|
return contacts.some(isProviderContactNamed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload de la sous-ressource contacts (groupe `provider:write:contacts`). Les
|
||||||
|
* chaines vides sont envoyees a null (le serveur normalise/trim de toute facon).
|
||||||
|
* `phoneSecondary` n'est envoye que si le 2e numero a ete revele (max 2 tel).
|
||||||
|
*/
|
||||||
|
export function buildProviderContactPayload(contact: ProviderContactFormDraft): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
firstName: contact.firstName || null,
|
||||||
|
lastName: contact.lastName || null,
|
||||||
|
jobTitle: contact.jobTitle || null,
|
||||||
|
phonePrimary: contact.phonePrimary || null,
|
||||||
|
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
|
||||||
|
email: contact.email || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* Helpers purs des ecrans Consultation / Modification prestataire (M3 Technique,
|
||||||
|
* ERP-145) — miroir SIMPLIFIE de `supplierConsultation.ts` (M2). Mappent le payload
|
||||||
|
* `GET /api/providers/{id}` (relations embarquees, cf. groupes `provider:item:read`
|
||||||
|
* + `provider:read:accounting`) vers les brouillons « plats » partages avec
|
||||||
|
* `ProviderContactBlock` / `ProviderAddressBlock` et l'onglet Comptabilite.
|
||||||
|
*
|
||||||
|
* Ne touchent ni a l'API ni a l'etat reactif (testables unitairement).
|
||||||
|
*
|
||||||
|
* Rappels de contrat back (JSON reel fige — ERP-139, spec-back § 4.0.bis) :
|
||||||
|
* - categories / sites du prestataire et des adresses : OBJETS embarques (avec @id) ;
|
||||||
|
* - refs comptables (tvaMode/paymentDelay/paymentType/bank) : OBJETS embarques
|
||||||
|
* `{@id, id, label, (code pour paymentType)}` ;
|
||||||
|
* - champs nuls OMIS (skip_null_values) → toujours lire avec `?? null` ;
|
||||||
|
* - champs comptables + `ribs` TOTALEMENT ABSENTS sans permission accounting.view.
|
||||||
|
*
|
||||||
|
* Differences M2 : pas de type d'adresse / bennes / triage, pas d'onglet Information.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { formatPhoneFR } from '~/shared/utils/phone'
|
||||||
|
import type {
|
||||||
|
ProviderAccountingDraft,
|
||||||
|
ProviderAddressFormDraft,
|
||||||
|
ProviderContactFormDraft,
|
||||||
|
ProviderRibFormDraft,
|
||||||
|
} from '~/modules/technique/types/providerForm'
|
||||||
|
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||||
|
|
||||||
|
/** Reference Hydra embarquee minimale (@id toujours present). */
|
||||||
|
export interface HydraRef {
|
||||||
|
'@id': string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */
|
||||||
|
export type Relation = HydraRef | string | null | undefined
|
||||||
|
|
||||||
|
/** Site embarque (groupe site:read). */
|
||||||
|
export interface SiteRead extends HydraRef {
|
||||||
|
name?: string
|
||||||
|
postalCode?: string
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Categorie embarquee (groupe category:read). */
|
||||||
|
export interface CategoryRead extends HydraRef {
|
||||||
|
code?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contact embarque (groupe provider:item:read). */
|
||||||
|
export interface ContactRead extends HydraRef {
|
||||||
|
id: number
|
||||||
|
firstName?: string | null
|
||||||
|
lastName?: string | null
|
||||||
|
jobTitle?: string | null
|
||||||
|
phonePrimary?: string | null
|
||||||
|
phoneSecondary?: string | null
|
||||||
|
email?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adresse embarquee (groupe provider:item:read) — version simplifiee M3. */
|
||||||
|
export interface AddressRead extends HydraRef {
|
||||||
|
id: number
|
||||||
|
country?: string | null
|
||||||
|
postalCode?: string | null
|
||||||
|
city?: string | null
|
||||||
|
street?: string | null
|
||||||
|
streetComplement?: string | null
|
||||||
|
sites?: SiteRead[]
|
||||||
|
categories?: CategoryRead[]
|
||||||
|
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
|
||||||
|
contacts?: Array<HydraRef | string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RIB embarque (groupe provider:read:accounting, present ssi accounting.view). */
|
||||||
|
export interface RibRead extends HydraRef {
|
||||||
|
id: number
|
||||||
|
label?: string | null
|
||||||
|
bic?: string | null
|
||||||
|
iban?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail d'un prestataire (`GET /api/providers/{id}`). Tous les champs sont
|
||||||
|
* optionnels : skip_null_values + gating accounting peuvent omettre n'importe
|
||||||
|
* quelle cle.
|
||||||
|
*/
|
||||||
|
export interface ProviderDetail extends HydraRef {
|
||||||
|
id: number
|
||||||
|
companyName?: string | null
|
||||||
|
isArchived?: boolean
|
||||||
|
categories?: CategoryRead[]
|
||||||
|
sites?: SiteRead[]
|
||||||
|
contacts?: ContactRead[]
|
||||||
|
addresses?: AddressRead[]
|
||||||
|
ribs?: RibRead[]
|
||||||
|
// Onglet Comptabilite (present ssi accounting.view)
|
||||||
|
siren?: string | null
|
||||||
|
accountNumber?: string | null
|
||||||
|
nTva?: string | null
|
||||||
|
tvaMode?: Relation
|
||||||
|
paymentDelay?: Relation
|
||||||
|
paymentType?: Relation
|
||||||
|
bank?: Relation
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */
|
||||||
|
export function iriOf(relation: Relation): string | null {
|
||||||
|
if (relation === null || relation === undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (typeof relation === 'string') {
|
||||||
|
return relation
|
||||||
|
}
|
||||||
|
return relation['@id'] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IRI des elements d'une collection embarquee (categories / sites du prestataire). */
|
||||||
|
export function irisOf(items: HydraRef[] | undefined): string[] {
|
||||||
|
return (items ?? []).map(i => i['@id'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */
|
||||||
|
export function mapContactToDraft(contact: ContactRead): ProviderContactFormDraft {
|
||||||
|
const phoneSecondary = contact.phoneSecondary ?? null
|
||||||
|
return {
|
||||||
|
id: contact.id,
|
||||||
|
iri: contact['@id'] ?? null,
|
||||||
|
firstName: contact.firstName ?? null,
|
||||||
|
lastName: contact.lastName ?? null,
|
||||||
|
jobTitle: contact.jobTitle ?? null,
|
||||||
|
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
|
||||||
|
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
|
||||||
|
email: contact.email ?? null,
|
||||||
|
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections). */
|
||||||
|
export function mapAddressToDraft(address: AddressRead): ProviderAddressFormDraft {
|
||||||
|
return {
|
||||||
|
id: address.id,
|
||||||
|
country: address.country ?? 'France',
|
||||||
|
postalCode: address.postalCode ?? null,
|
||||||
|
city: address.city ?? null,
|
||||||
|
street: address.street ?? null,
|
||||||
|
streetComplement: address.streetComplement ?? null,
|
||||||
|
categoryIris: (address.categories ?? []).map(c => c['@id']),
|
||||||
|
siteIris: (address.sites ?? []).map(s => s['@id']),
|
||||||
|
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mappe un RIB embarque vers un brouillon. */
|
||||||
|
export function mapRibToDraft(rib: RibRead): ProviderRibFormDraft {
|
||||||
|
return {
|
||||||
|
id: rib.id,
|
||||||
|
label: rib.label ?? null,
|
||||||
|
bic: rib.bic ?? null,
|
||||||
|
iban: rib.iban ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mappe les champs comptables (scalaires + IRI des referentiels embarques). */
|
||||||
|
export function mapAccountingDraft(provider: ProviderDetail): ProviderAccountingDraft {
|
||||||
|
return {
|
||||||
|
siren: provider.siren ?? null,
|
||||||
|
accountNumber: provider.accountNumber ?? null,
|
||||||
|
nTva: provider.nTva ?? null,
|
||||||
|
tvaModeIri: iriOf(provider.tvaMode),
|
||||||
|
paymentDelayIri: iriOf(provider.paymentDelay),
|
||||||
|
paymentTypeIri: iriOf(provider.paymentType),
|
||||||
|
bankIri: iriOf(provider.bank),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options de categories (value=IRI, label=nom) construites depuis l'embed.
|
||||||
|
* Source role-independante : evite de dependre de `GET /categories` (403 possible
|
||||||
|
* pour un role metier), qui laisserait les libelles vides en consultation.
|
||||||
|
*/
|
||||||
|
export function categoryOptionsOf(categories: CategoryRead[] | undefined): RefOption[] {
|
||||||
|
return (categories ?? []).map(c => ({
|
||||||
|
value: c['@id'],
|
||||||
|
label: c.name ?? c.code ?? c['@id'],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options de sites (value=IRI, label=nom) construites depuis un embed. */
|
||||||
|
export function siteOptionsOf(sites: SiteRead[] | undefined): RefOption[] {
|
||||||
|
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed prestataire. */
|
||||||
|
export function contactOptionsOf(contacts: ContactRead[] | undefined): RefOption[] {
|
||||||
|
return (contacts ?? []).map(c => ({
|
||||||
|
value: c['@id'],
|
||||||
|
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste a une seule option (ou vide) construite depuis un referentiel embarque
|
||||||
|
* (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en
|
||||||
|
* lecture seule. Le libelle vient de l'embed, jamais d'un GET de referentiel —
|
||||||
|
* l'affichage reste correct quel que soit le role.
|
||||||
|
*/
|
||||||
|
export function referentialOptionOf(relation: Relation): RefOption[] {
|
||||||
|
if (!relation || typeof relation === 'string') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const label = (relation.label as string | undefined)
|
||||||
|
?? (relation.name as string | undefined)
|
||||||
|
?? relation['@id']
|
||||||
|
return [{ value: relation['@id'], label }]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Code metier d'un referentiel embarque (PaymentType.code = 'LCR' / 'VIREMENT'), ou null. */
|
||||||
|
export function paymentTypeCodeOf(relation: Relation): string | null {
|
||||||
|
if (!relation || typeof relation === 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (relation.code as string | undefined) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet —
|
||||||
|
* `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir
|
||||||
|
* ouvrir l'edition pour son onglet Comptabilite). Le readonly fin par onglet est
|
||||||
|
* gere sur l'ecran d'edition.
|
||||||
|
*/
|
||||||
|
export function canEditProvider(canAny: (codes: string[]) => boolean): boolean {
|
||||||
|
return canAny(['technique.providers.manage', 'technique.providers.accounting.manage'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bouton « Archiver » : permission archive ET prestataire encore actif (Admin seul). */
|
||||||
|
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
||||||
|
return can('technique.providers.archive') && !isArchived
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bouton « Restaurer » : permission archive ET prestataire deja archive (Admin seul). */
|
||||||
|
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
||||||
|
return can('technique.providers.archive') && isArchived
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export default defineNuxtConfig({})
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/** 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
|
||||||
|
}
|
||||||
@@ -250,6 +250,14 @@ sync-permissions:
|
|||||||
seed-rbac:
|
seed-rbac:
|
||||||
$(SYMFONY_CONSOLE) --no-interaction app:seed-rbac
|
$(SYMFONY_CONSOLE) --no-interaction app:seed-rbac
|
||||||
|
|
||||||
|
# Synchronise le referentiel des transporteurs QUALIMAT (ERP-39) : upsert sur
|
||||||
|
# le SIRET + soft-delete des absents + journal. Idempotent (refresh complet),
|
||||||
|
# prevu pour un cron quotidien.
|
||||||
|
# Options : --dry-run (analyse sans ecriture), --file=<chemin.json> (source
|
||||||
|
# locale au lieu de l'API), --ppp=<n> (taille de page API, defaut 10000).
|
||||||
|
qualimat-sync:
|
||||||
|
$(SYMFONY_CONSOLE) --no-interaction app:qualimat:sync
|
||||||
|
|
||||||
# Attention, supprime votre bdd local
|
# Attention, supprime votre bdd local
|
||||||
db-reset:
|
db-reset:
|
||||||
$(DOCKER_COMPOSE) down -v
|
$(DOCKER_COMPOSE) down -v
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-3.04 (correctif) — aligne la regle de validite d'un contact prestataire sur
|
||||||
|
* le M1/M2 : au moins le PRENOM OU le NOM (et non plus « un champ quelconque parmi
|
||||||
|
* prenom/nom/fonction/telephone/email »). Remplace le CHECK chk_provider_contact_name
|
||||||
|
* et met a jour les commentaires de colonnes. La garde applicative
|
||||||
|
* (ProviderContactProcessor::validateName) est alignee dans le meme commit.
|
||||||
|
*
|
||||||
|
* Placee au namespace racine DoctrineMigrations (et non en modulaire Technique) :
|
||||||
|
* elle ALTERE une table creee par une migration racine (Version20260612100000) ;
|
||||||
|
* le tri par version au sein du meme namespace garantit qu'elle joue APRES l'init
|
||||||
|
* (cf. CLAUDE.md regle 11 — le tri cross-namespace casserait l'ordre sur base vide).
|
||||||
|
*/
|
||||||
|
final class Version20260615120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'RG-3.04 : contact prestataire valide si prenom OU nom (alignement M1/M2) — CHECK chk_provider_contact_name.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name');
|
||||||
|
$this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)');
|
||||||
|
|
||||||
|
$this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).$_$');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name');
|
||||||
|
$this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL)');
|
||||||
|
|
||||||
|
$this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/fonction/telephone/email (RG-3.04, chk_provider_contact_name).$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
|
||||||
|
$this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Technique\Application\Validator;
|
||||||
|
|
||||||
|
use ApiPlatform\Validator\Exception\ValidationException;
|
||||||
|
use App\Module\Technique\Domain\Entity\Provider;
|
||||||
|
use Symfony\Component\Validator\ConstraintViolation;
|
||||||
|
use Symfony\Component\Validator\ConstraintViolationList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validator metier (spec-front M3 § Onglet Comptabilite — jumeau de
|
||||||
|
* SupplierAccountingCompletenessValidator M2) : a la soumission complete de
|
||||||
|
* l'onglet Comptabilite, les six champs scalaires obligatoires doivent etre
|
||||||
|
* renseignes (SIREN, Numero de compte, Mode de TVA, N de TVA, Delai de reglement,
|
||||||
|
* Type de reglement). La banque reste conditionnelle (RG-3.07) et les RIB aussi
|
||||||
|
* (RG-3.08) : ils ne sont pas couverts ici (Assert\Callback sur l'entite Provider
|
||||||
|
* — validatePaymentTypeConsistency).
|
||||||
|
*
|
||||||
|
* Parti pris (miroir M1/M2) : colonnes nullable en base + validateur contextuel,
|
||||||
|
* plutot qu'un Assert\NotBlank sur l'entite (qui casserait le POST de l'onglet
|
||||||
|
* principal, lequel n'envoie aucun champ comptable).
|
||||||
|
*
|
||||||
|
* Invoque par le ProviderProcessor uniquement quand le payload porte les six
|
||||||
|
* champs (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ.
|
||||||
|
*
|
||||||
|
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
|
||||||
|
* coherence avec les violations Symfony rendues par API Platform (mapping inline
|
||||||
|
* front via useFormErrors, ERP-101).
|
||||||
|
*/
|
||||||
|
final class ProviderAccountingCompletenessValidator
|
||||||
|
{
|
||||||
|
public function validate(Provider $provider): void
|
||||||
|
{
|
||||||
|
// Map champ -> valeur courante des champs obligatoires de l'onglet.
|
||||||
|
$fields = [
|
||||||
|
'siren' => $provider->getSiren(),
|
||||||
|
'accountNumber' => $provider->getAccountNumber(),
|
||||||
|
'tvaMode' => $provider->getTvaMode(),
|
||||||
|
'nTva' => $provider->getNTva(),
|
||||||
|
'paymentDelay' => $provider->getPaymentDelay(),
|
||||||
|
'paymentType' => $provider->getPaymentType(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$violations = new ConstraintViolationList();
|
||||||
|
|
||||||
|
foreach ($fields as $property => $value) {
|
||||||
|
if ($this->isMissing($value)) {
|
||||||
|
$violations->add(new ConstraintViolation(
|
||||||
|
'Ce champ est obligatoire.',
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
$provider,
|
||||||
|
$property,
|
||||||
|
$value,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($violations) > 0) {
|
||||||
|
throw new ValidationException($violations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les
|
||||||
|
* references (TvaMode / PaymentDelay / PaymentType) ne sont manquantes que
|
||||||
|
* lorsqu'elles valent null.
|
||||||
|
*/
|
||||||
|
private function isMissing(mixed $value): bool
|
||||||
|
{
|
||||||
|
if (null === $value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_string($value) && '' === trim($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-12
@@ -119,23 +119,18 @@ final class ProviderContactProcessor implements ProcessorInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-3.04 : un bloc Contact est valide des qu'au moins un champ parmi prenom /
|
* RG-3.04 : un bloc Contact exige au moins le prenom OU le nom (aligne sur le
|
||||||
* nom / fonction / telephone principal / email est renseigne (double garde avec
|
* M1/M2 — un contact se materialise par son nom ; fonction / telephone / email
|
||||||
* le CHECK BDD chk_provider_contact_name — leve une 422 propre rattachee au
|
* seuls ne suffisent pas). Double garde avec le CHECK BDD chk_provider_contact_name
|
||||||
* champ `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
|
* — leve une 422 propre rattachee au champ `firstName` plutot qu'une 500 SQL.
|
||||||
* chaines vides (y compris une fonction ou un phone_secondary vides) sont deja
|
* Joue apres normalisation (les chaines vides sont deja ramenees a null).
|
||||||
* ramenees a null et ne suffisent pas a valider le bloc.
|
|
||||||
*/
|
*/
|
||||||
private function validateName(ProviderContact $contact): void
|
private function validateName(ProviderContact $contact): void
|
||||||
{
|
{
|
||||||
if (null === $contact->getFirstName()
|
if (null === $contact->getFirstName() && null === $contact->getLastName()) {
|
||||||
&& null === $contact->getLastName()
|
|
||||||
&& null === $contact->getJobTitle()
|
|
||||||
&& null === $contact->getPhonePrimary()
|
|
||||||
&& null === $contact->getEmail()) {
|
|
||||||
$violations = new ConstraintViolationList();
|
$violations = new ConstraintViolationList();
|
||||||
$violations->add(new ConstraintViolation(
|
$violations->add(new ConstraintViolation(
|
||||||
'Au moins un champ du contact est obligatoire (nom, prénom, fonction, téléphone ou email).',
|
'Le prénom ou le nom du contact est obligatoire.',
|
||||||
null,
|
null,
|
||||||
[],
|
[],
|
||||||
$contact,
|
$contact,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use ApiPlatform\State\ProcessorInterface;
|
|||||||
use ApiPlatform\Validator\Exception\ValidationException;
|
use ApiPlatform\Validator\Exception\ValidationException;
|
||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
||||||
|
use App\Module\Technique\Application\Validator\ProviderAccountingCompletenessValidator;
|
||||||
use App\Module\Technique\Domain\Entity\Provider;
|
use App\Module\Technique\Domain\Entity\Provider;
|
||||||
use App\Shared\Domain\Contract\SiteInterface;
|
use App\Shared\Domain\Contract\SiteInterface;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
@@ -75,6 +76,15 @@ final class ProviderProcessor implements ProcessorInterface
|
|||||||
'paymentType', 'bank',
|
'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). */
|
/** Champ d'archivage (groupe provider:write:archive). */
|
||||||
private const string ARCHIVE_FIELD = 'isArchived';
|
private const string ARCHIVE_FIELD = 'isArchived';
|
||||||
|
|
||||||
@@ -102,6 +112,7 @@ final class ProviderProcessor implements ProcessorInterface
|
|||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
private readonly RequestStack $requestStack,
|
private readonly RequestStack $requestStack,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly ProviderAccountingCompletenessValidator $accountingValidator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||||
@@ -128,6 +139,10 @@ final class ProviderProcessor implements ProcessorInterface
|
|||||||
// deux cotes (l'etat persiste l'a deja ete).
|
// deux cotes (l'etat persiste l'a deja ete).
|
||||||
$this->guardManage($data);
|
$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 {
|
try {
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
} catch (UniqueConstraintViolationException $e) {
|
} catch (UniqueConstraintViolationException $e) {
|
||||||
@@ -496,6 +511,21 @@ final class ProviderProcessor implements ProcessorInterface
|
|||||||
*
|
*
|
||||||
* @return list<string>
|
* @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
|
private function payloadKeys(): array
|
||||||
{
|
{
|
||||||
$request = $this->requestStack->getCurrentRequest();
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Transport\Application\Qualimat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping pur d'un item brut de l'API QUALIMAT vers une ligne normalisee
|
||||||
|
* prete a l'upsert dans `qualimat_carrier`. Sans dependance (testable en
|
||||||
|
* isolation). Voir ERP-39 § 2 pour les pieges qualite de la source.
|
||||||
|
*/
|
||||||
|
final class QualimatRowMapper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Mappe un lot d'items. Les items sans SIRET exploitable sont ignores et
|
||||||
|
* comptes a part (cf. `rows_skipped` du journal). Les doublons de SIRET
|
||||||
|
* (source "sale" : memes chiffres a separateurs pres) sont fusionnes,
|
||||||
|
* derniere occurrence gagnante — l'upsert ne verrait qu'une ligne de toute
|
||||||
|
* facon, et le compte `rows_upserted` reflete ainsi les transporteurs
|
||||||
|
* distincts.
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $items
|
||||||
|
*
|
||||||
|
* @return array{rows: list<array<string, mixed>>, skipped: int}
|
||||||
|
*/
|
||||||
|
public static function mapMany(array $items): array
|
||||||
|
{
|
||||||
|
$bySiret = [];
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$row = self::mapOne($item);
|
||||||
|
|
||||||
|
if (null === $row) {
|
||||||
|
++$skipped;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cle = SIRET normalise : une occurrence ulterieure ecrase la
|
||||||
|
// precedente (derniere gagnante).
|
||||||
|
$bySiret[$row['siret']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['rows' => array_values($bySiret), 'skipped' => $skipped];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mappe un item unique. Retourne null si le SIRET est absent ou vide
|
||||||
|
* (ligne inexploitable : pas de cle naturelle pour l'upsert).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $item
|
||||||
|
*
|
||||||
|
* @return null|array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function mapOne(array $item): ?array
|
||||||
|
{
|
||||||
|
$siret = self::normalizeSiret(self::str($item['Siret'] ?? null));
|
||||||
|
|
||||||
|
if (null === $siret) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'siret' => $siret,
|
||||||
|
// Nom et Societe sont identiques a la source : une seule colonne.
|
||||||
|
'name' => self::str($item['Nom'] ?? null) ?? '',
|
||||||
|
'address' => self::str($item['Adresse'] ?? null),
|
||||||
|
'postal_code' => self::str($item['CodePostal'] ?? null),
|
||||||
|
'city' => self::str($item['Ville'] ?? null),
|
||||||
|
'phone' => self::str($item['Telephone_1'] ?? null),
|
||||||
|
'department' => self::str($item['Departement'] ?? null),
|
||||||
|
// Statut conserve brut (feed externe, valeurs non contraintes).
|
||||||
|
'status' => self::str($item['Statut'] ?? null) ?? '',
|
||||||
|
'validity_date' => self::parseDate(self::str($item['Validite'] ?? null)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise un SIRET : ne conserve que les chiffres. Null si vide.
|
||||||
|
* La source est "sale" (longueurs variables 7 a 14) : aucune contrainte
|
||||||
|
* de longueur, on stocke les chiffres tels quels.
|
||||||
|
*/
|
||||||
|
public static function normalizeSiret(?string $raw): ?string
|
||||||
|
{
|
||||||
|
if (null === $raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$digits = preg_replace('/\D+/', '', $raw) ?? '';
|
||||||
|
|
||||||
|
return '' === $digits ? null : $digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit une date "dd/mm/yyyy" en "yyyy-mm-dd". Null si le format ne
|
||||||
|
* correspond pas ou si la date n'est pas un jour calendaire valide
|
||||||
|
* (garde-fou : evite un INSERT en erreur sur une date impossible).
|
||||||
|
*/
|
||||||
|
public static function parseDate(?string $raw): ?string
|
||||||
|
{
|
||||||
|
if (null === $raw || !preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $raw, $m)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$day = (int) $m[1];
|
||||||
|
$month = (int) $m[2];
|
||||||
|
$year = (int) $m[3];
|
||||||
|
|
||||||
|
if (!checkdate($month, $day, $year)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trim d'une valeur scalaire ; null si la chaine resultante est vide.
|
||||||
|
*/
|
||||||
|
private static function str(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (null === $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim((string) $value);
|
||||||
|
|
||||||
|
return '' === $trimmed ? null : $trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Transport\Infrastructure\Console;
|
||||||
|
|
||||||
|
use App\Module\Transport\Application\Qualimat\QualimatRowMapper;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
use function array_slice;
|
||||||
|
use function count;
|
||||||
|
use function is_array;
|
||||||
|
|
||||||
|
use const JSON_THROW_ON_ERROR;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-39 : synchronise le referentiel des transporteurs QUALIMAT.
|
||||||
|
*
|
||||||
|
* Recupere la liste des operateurs de transport depuis l'API publique (ou un
|
||||||
|
* fichier local), normalise chaque ligne et synchronise `qualimat_carrier` de
|
||||||
|
* facon transactionnelle : upsert sur le SIRET, soft-delete des absents,
|
||||||
|
* journal dans `qualimat_sync_log`. Idempotente (refresh complet) : prevue
|
||||||
|
* pour un cron quotidien.
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:qualimat:sync',
|
||||||
|
description: 'Synchronise le referentiel des transporteurs QUALIMAT (upsert + soft-delete + journal).',
|
||||||
|
)]
|
||||||
|
final class SyncQualimatCommand extends Command
|
||||||
|
{
|
||||||
|
private const string API_URL = 'https://www.qualimat.org/wp-json/qualimat/v1/getOperateurs';
|
||||||
|
private const int DEFAULT_PPP = 10000;
|
||||||
|
|
||||||
|
// Cle arbitraire (mais stable) du verrou consultatif Postgres serialisant
|
||||||
|
// les runs de `app:qualimat:sync` entre eux. Propre a cette commande.
|
||||||
|
private const int ADVISORY_LOCK_KEY = 3_900_000_039;
|
||||||
|
|
||||||
|
// Nombre de lignes par INSERT groupe. 10 parametres/ligne, large marge sous
|
||||||
|
// la limite Postgres de 65535 parametres par requete.
|
||||||
|
private const int UPSERT_CHUNK = 1000;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Connection $connection,
|
||||||
|
private readonly HttpClientInterface $httpClient,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption('file', null, InputOption::VALUE_REQUIRED, "Chemin d'un JSON local (court-circuite l'appel HTTP, utile pour tests/rejeu).")
|
||||||
|
->addOption('ppp', null, InputOption::VALUE_REQUIRED, "Taille de page demandee a l'API.", (string) self::DEFAULT_PPP)
|
||||||
|
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Analyse sans ecriture en base.')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$ppp = max(1, (int) $input->getOption('ppp'));
|
||||||
|
$dryRun = (bool) $input->getOption('dry-run');
|
||||||
|
$file = $input->getOption('file');
|
||||||
|
|
||||||
|
// Verrou consultatif (session) : empeche deux runs de se chevaucher
|
||||||
|
// (cron qui deborde, invocation manuelle parallele). Sans lui, le run le
|
||||||
|
// plus tardif desactiverait les lignes que l'autre vient d'inserer.
|
||||||
|
if (!$this->acquireLock()) {
|
||||||
|
$io->error('Une synchronisation QUALIMAT est deja en cours (verrou non disponible).');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->doSync($io, $ppp, $dryRun, $file);
|
||||||
|
} finally {
|
||||||
|
$this->releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coeur de la synchronisation, execute sous verrou consultatif.
|
||||||
|
*/
|
||||||
|
private function doSync(SymfonyStyle $io, int $ppp, bool $dryRun, ?string $file): int
|
||||||
|
{
|
||||||
|
// 1. Recuperation des items (fichier local ou API).
|
||||||
|
try {
|
||||||
|
$items = null !== $file ? $this->readLocal($file) : $this->fetchRemote($ppp);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$io->error('Recuperation impossible : '.$e->getMessage());
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = count($items);
|
||||||
|
$io->section(sprintf('QUALIMAT — %d items recus', $total));
|
||||||
|
|
||||||
|
// Garde-fou troncature : un retour egal a ppp signale un dataset coupe.
|
||||||
|
if (null === $file && $total === $ppp) {
|
||||||
|
$io->warning(sprintf("Le nombre d'items recus (%d) egale --ppp : resultat potentiellement tronque, augmente --ppp.", $ppp));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Mapping / normalisation (les items sans SIRET sont ignores, les
|
||||||
|
// doublons de SIRET sont fusionnes : derniere occurrence gagnante).
|
||||||
|
['rows' => $rows, 'skipped' => $skipped] = QualimatRowMapper::mapMany($items);
|
||||||
|
$io->writeln(sprintf('%d lignes exploitables, %d ignorees (sans SIRET).', count($rows), $skipped));
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->renderPreview($io, $rows);
|
||||||
|
$io->note(sprintf('Dry-run : aucune ecriture. (%d lignes au total)', count($rows)));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garde-fou « zero ligne » : une source vide (incident amont, liste []
|
||||||
|
// legitime) ne doit JAMAIS atteindre le soft-delete, qui desactiverait
|
||||||
|
// tout le referentiel. On abandonne sans rien ecrire.
|
||||||
|
if ([] === $rows) {
|
||||||
|
$io->error('Aucune ligne exploitable : synchronisation abandonnee (desactivation de masse evitee).');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Sync transactionnelle : upsert -> soft-delete -> journal.
|
||||||
|
$run = new DateTimeImmutable()->format('Y-m-d H:i:s.u');
|
||||||
|
|
||||||
|
$this->connection->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$upserted = $this->upsertAll($rows, $run);
|
||||||
|
$deactivated = $this->deactivateMissing($run);
|
||||||
|
$this->log($run, $total, $upserted, $skipped, $deactivated);
|
||||||
|
$this->connection->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->connection->rollBack();
|
||||||
|
$io->error('Sync annulee (rollback) : '.$e->getMessage());
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success(sprintf('%d upsert, %d ignore(s), %d desactive(s).', $upserted, $skipped, $deactivated));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tente de prendre le verrou consultatif de session. Retourne false si un
|
||||||
|
* autre run le detient deja (Postgres `pg_try_advisory_lock`, non bloquant).
|
||||||
|
*/
|
||||||
|
private function acquireLock(): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->connection->fetchOne('SELECT pg_try_advisory_lock(:key)', ['key' => self::ADVISORY_LOCK_KEY]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relache le verrou consultatif pris par acquireLock().
|
||||||
|
*/
|
||||||
|
private function releaseLock(): void
|
||||||
|
{
|
||||||
|
$this->connection->executeStatement('SELECT pg_advisory_unlock(:key)', ['key' => self::ADVISORY_LOCK_KEY]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejoue l'appel GET de l'API QUALIMAT et retourne le tableau d'items.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function fetchRemote(int $ppp): array
|
||||||
|
{
|
||||||
|
$response = $this->httpClient->request('GET', self::API_URL, [
|
||||||
|
'query' => ['type' => 'operateur_transport', 'ppp' => $ppp],
|
||||||
|
'timeout' => 60,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// toArray() leve une exception sur un statut non-2xx ou un corps non-JSON.
|
||||||
|
$data = $response->toArray();
|
||||||
|
|
||||||
|
// Un 2xx au corps inattendu (objet d'erreur, enveloppe {"data":[...]}, etc.)
|
||||||
|
// ne doit PAS etre interprete comme « 0 transporteur » : ce serait masquer
|
||||||
|
// un changement de contrat de l'API et declencher la desactivation de masse
|
||||||
|
// (cf. garde-fou « zero ligne » dans execute()). On echoue franchement.
|
||||||
|
if (!array_is_list($data)) {
|
||||||
|
throw new RuntimeException("Reponse inattendue de l'API QUALIMAT : un tableau d'items etait attendu.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit un export JSON local (tableau d'objets).
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function readLocal(string $path): array
|
||||||
|
{
|
||||||
|
$raw = @file_get_contents($path);
|
||||||
|
|
||||||
|
if (false === $raw) {
|
||||||
|
throw new RuntimeException(sprintf('Fichier illisible : %s', $path));
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
if (!is_array($data) || !array_is_list($data)) {
|
||||||
|
throw new RuntimeException("Le JSON doit etre un tableau d'objets.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert de toutes les lignes valides (cle naturelle = siret) par paquets
|
||||||
|
* (INSERT groupe), au lieu d'un aller-retour par ligne. Marque is_active=TRUE
|
||||||
|
* et tamponne last_synced_at avec le run courant. Les lignes etant deja
|
||||||
|
* dedoublonnees par SIRET en amont, le compte retourne = transporteurs
|
||||||
|
* distincts effectivement synchronises.
|
||||||
|
*
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
*/
|
||||||
|
private function upsertAll(array $rows, string $run): int
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
foreach (array_chunk($rows, self::UPSERT_CHUNK) as $chunk) {
|
||||||
|
$placeholders = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
foreach ($chunk as $r) {
|
||||||
|
// 10 valeurs liees + is_active force a TRUE (litteral).
|
||||||
|
$placeholders[] = '(?, ?, ?, ?, ?, ?, ?, ?, ?, TRUE, ?)';
|
||||||
|
$params[] = $r['siret'];
|
||||||
|
$params[] = $r['name'];
|
||||||
|
$params[] = $r['address'];
|
||||||
|
$params[] = $r['postal_code'];
|
||||||
|
$params[] = $r['city'];
|
||||||
|
$params[] = $r['phone'];
|
||||||
|
$params[] = $r['department'];
|
||||||
|
$params[] = $r['status'];
|
||||||
|
$params[] = $r['validity_date'];
|
||||||
|
$params[] = $run;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = sprintf(
|
||||||
|
<<<'SQL'
|
||||||
|
INSERT INTO qualimat_carrier
|
||||||
|
(siret, name, address, postal_code, city, phone, department, status, validity_date, is_active, last_synced_at)
|
||||||
|
VALUES
|
||||||
|
%s
|
||||||
|
ON CONFLICT (siret) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
address = EXCLUDED.address,
|
||||||
|
postal_code = EXCLUDED.postal_code,
|
||||||
|
city = EXCLUDED.city,
|
||||||
|
phone = EXCLUDED.phone,
|
||||||
|
department = EXCLUDED.department,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
validity_date = EXCLUDED.validity_date,
|
||||||
|
is_active = TRUE,
|
||||||
|
last_synced_at = EXCLUDED.last_synced_at
|
||||||
|
SQL,
|
||||||
|
implode(",\n ", $placeholders),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->connection->executeStatement($sql, $params);
|
||||||
|
$count += count($chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete : toute ligne active non revue par ce run (tampon anterieur)
|
||||||
|
* passe a is_active=false.
|
||||||
|
*/
|
||||||
|
private function deactivateMissing(string $run): int
|
||||||
|
{
|
||||||
|
return (int) $this->connection->executeStatement(
|
||||||
|
'UPDATE qualimat_carrier SET is_active = FALSE WHERE is_active = TRUE AND last_synced_at < :run',
|
||||||
|
['run' => $run],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function log(string $run, int $total, int $upserted, int $skipped, int $deactivated): void
|
||||||
|
{
|
||||||
|
$this->connection->executeStatement(
|
||||||
|
<<<'SQL'
|
||||||
|
INSERT INTO qualimat_sync_log (fetched_at, rows_total, rows_upserted, rows_skipped, rows_deactivated)
|
||||||
|
VALUES (:run, :total, :upserted, :skipped, :deactivated)
|
||||||
|
SQL,
|
||||||
|
[
|
||||||
|
'run' => $run,
|
||||||
|
'total' => $total,
|
||||||
|
'upserted' => $upserted,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'deactivated' => $deactivated,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
*/
|
||||||
|
private function renderPreview(SymfonyStyle $io, array $rows): void
|
||||||
|
{
|
||||||
|
$io->table(
|
||||||
|
['SIRET', 'Nom', 'CP', 'Ville', 'Statut', 'Validite'],
|
||||||
|
array_map(static fn (array $r): array => [
|
||||||
|
(string) $r['siret'],
|
||||||
|
mb_strimwidth((string) $r['name'], 0, 40, '…'),
|
||||||
|
(string) ($r['postal_code'] ?? ''),
|
||||||
|
mb_strimwidth((string) ($r['city'] ?? ''), 0, 25, '…'),
|
||||||
|
(string) $r['status'],
|
||||||
|
(string) ($r['validity_date'] ?? ''),
|
||||||
|
], array_slice($rows, 0, 15)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Transport\Infrastructure\Doctrine\Migrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-39 (Module Transport) : referentiel des transporteurs agrees QUALIMAT.
|
||||||
|
*
|
||||||
|
* Tables alimentees par la commande de synchronisation `app:qualimat:sync`
|
||||||
|
* (upsert sur le SIRET + soft-delete des absents + journal). Aucune FK
|
||||||
|
* cross-module (referentiel autonome) : migration au namespace modulaire
|
||||||
|
* Transport. Tables autonomes, sans dependance d'ordre vis-a-vis des autres
|
||||||
|
* migrations, donc insensible au tri cross-namespace de Doctrine Migrations.
|
||||||
|
*/
|
||||||
|
final class Version20260612150000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'ERP-39 : tables qualimat_carrier + qualimat_sync_log (referentiel transporteurs QUALIMAT, synchro console).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE qualimat_carrier (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
siret VARCHAR(20) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
address VARCHAR(255) DEFAULT NULL,
|
||||||
|
postal_code VARCHAR(10) DEFAULT NULL,
|
||||||
|
city VARCHAR(255) DEFAULT NULL,
|
||||||
|
phone VARCHAR(32) DEFAULT NULL,
|
||||||
|
department VARCHAR(64) DEFAULT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
validity_date DATE DEFAULT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||||
|
last_synced_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uq_qualimat_carrier_siret UNIQUE (siret)
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX idx_qualimat_carrier_active ON qualimat_carrier (is_active)');
|
||||||
|
|
||||||
|
$this->comment('qualimat_carrier', '_table', "Referentiel des transporteurs agrees QUALIMAT, synchronise quotidiennement depuis l'API qualimat.org (type=operateur_transport).");
|
||||||
|
$this->comment('qualimat_carrier', 'id', 'Cle technique auto-incrementee.');
|
||||||
|
$this->comment('qualimat_carrier', 'siret', 'SIRET normalise (chiffres sans espaces). Cle naturelle de synchro (unique). Source parfois incomplete (longueur variable), non contrainte a 14.');
|
||||||
|
$this->comment('qualimat_carrier', 'name', 'Raison sociale du transporteur (champs Nom = Societe de la source, identiques).');
|
||||||
|
$this->comment('qualimat_carrier', 'address', 'Adresse postale (voie). Nullable.');
|
||||||
|
$this->comment('qualimat_carrier', 'postal_code', 'Code postal. Nullable.');
|
||||||
|
$this->comment('qualimat_carrier', 'city', 'Ville. Nullable.');
|
||||||
|
$this->comment('qualimat_carrier', 'phone', 'Telephone au format source "indicatif|numero" (ex: +33|0608890316). Nullable.');
|
||||||
|
$this->comment('qualimat_carrier', 'department', 'Departement au format source "code - libelle" (ex: 65 - Hautes-Pyrenees). Nullable.');
|
||||||
|
$this->comment('qualimat_carrier', 'status', "Statut d'agrement QUALIMAT (valeurs connues : Audite, Valide, Suspendu). Valeur brute de la source, non contrainte.");
|
||||||
|
$this->comment('qualimat_carrier', 'validity_date', 'Date de fin de validite de la certification (convertie depuis dd/mm/yyyy). Nullable.');
|
||||||
|
$this->comment('qualimat_carrier', 'is_active', 'Faux = transporteur absent du dernier import (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.');
|
||||||
|
$this->comment('qualimat_carrier', 'last_synced_at', 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).');
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE qualimat_sync_log (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
fetched_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
|
||||||
|
rows_total INT NOT NULL,
|
||||||
|
rows_upserted INT NOT NULL,
|
||||||
|
rows_skipped INT NOT NULL,
|
||||||
|
rows_deactivated INT NOT NULL,
|
||||||
|
created_at TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW() NOT NULL,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->comment('qualimat_sync_log', '_table', 'Journal des synchronisations QUALIMAT (une ligne par run de la commande app:qualimat:sync).');
|
||||||
|
$this->comment('qualimat_sync_log', 'id', 'Cle technique auto-incrementee.');
|
||||||
|
$this->comment('qualimat_sync_log', 'fetched_at', "Horodatage de l'appel a l'API source (= run de synchro).");
|
||||||
|
$this->comment('qualimat_sync_log', 'rows_total', "Nombre d'items renvoyes par l'API.");
|
||||||
|
$this->comment('qualimat_sync_log', 'rows_upserted', 'Nombre de transporteurs inseres ou mis a jour.');
|
||||||
|
$this->comment('qualimat_sync_log', 'rows_skipped', "Nombre d'items ignores (sans SIRET exploitable).");
|
||||||
|
$this->comment('qualimat_sync_log', 'rows_deactivated', 'Nombre de transporteurs passes a is_active=false (absents de cet import).');
|
||||||
|
$this->comment('qualimat_sync_log', 'created_at', 'Horodatage de fin du run (insertion du journal).');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS qualimat_sync_log');
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS qualimat_carrier');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pose un COMMENT ON TABLE/COLUMN en dollar-quoting Postgres ($_$...$_$)
|
||||||
|
* pour eviter tout echappement d'apostrophes dans les descriptions.
|
||||||
|
*/
|
||||||
|
private function comment(string $table, string $column, string $description): void
|
||||||
|
{
|
||||||
|
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||||
|
|
||||||
|
if ('_table' === $column) {
|
||||||
|
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql(sprintf(
|
||||||
|
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||||
|
$quotedTable,
|
||||||
|
'"'.str_replace('"', '""', $column).'"',
|
||||||
|
$description,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Transport;
|
||||||
|
|
||||||
|
final class TransportModule
|
||||||
|
{
|
||||||
|
public const string ID = 'transport';
|
||||||
|
public const string LABEL = 'Transport';
|
||||||
|
public const bool REQUIRED = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste declarative des permissions RBAC exposees par le module Transport.
|
||||||
|
*
|
||||||
|
* Vide a ce stade : le module ne porte que des referentiels externes
|
||||||
|
* synchronises par commandes console (codes IDTF - ERP-149, transporteurs
|
||||||
|
* QUALIMAT - ERP-39), sans ecran ni action protegee. Les permissions seront
|
||||||
|
* ajoutees quand une page de consultation sera exposee.
|
||||||
|
*
|
||||||
|
* Consommee par `app:sync-permissions` (un tableau vide est valide).
|
||||||
|
*
|
||||||
|
* @return array<int, array{code: string, label: string}>
|
||||||
|
*/
|
||||||
|
public static function permissions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?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 {}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?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),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?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,6 +36,18 @@ final class ColumnCommentsCatalog
|
|||||||
public static function comments(): array
|
public static function comments(): array
|
||||||
{
|
{
|
||||||
return [
|
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' => [
|
'audit_log' => [
|
||||||
'_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.",
|
'_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).",
|
'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).",
|
||||||
@@ -395,12 +407,12 @@ final class ColumnCommentsCatalog
|
|||||||
],
|
],
|
||||||
|
|
||||||
'provider_contact' => [
|
'provider_contact' => [
|
||||||
'_table' => 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/telephone/email (RG-3.04, chk_provider_contact_name).',
|
'_table' => 'Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).',
|
||||||
'id' => 'Identifiant interne auto-incremente.',
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.',
|
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.',
|
||||||
'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).',
|
'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). Au moins un champ du contact requis (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).',
|
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).',
|
||||||
'phone_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).',
|
'phone_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).',
|
||||||
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).',
|
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).',
|
||||||
'email' => 'Email du contact (lowercase serveur).',
|
'email' => 'Email du contact (lowercase serveur).',
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,9 @@ namespace App\Tests\Module\Technique\Api;
|
|||||||
* de l'entite Provider (M3, RG-3.07 / RG-3.08), via le PATCH de l'onglet
|
* 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
|
* Comptabilite (groupe provider:write:accounting). On asserte le code HTTP et le
|
||||||
* propertyPath de la violation (consommable par extractApiViolations cote front,
|
* propertyPath de la violation (consommable par extractApiViolations cote front,
|
||||||
* ERP-101). Jumeau de SupplierAccountingApiTest (M2), sans le bloc « completude de
|
* ERP-101). Jumeau de SupplierAccountingApiTest (M2), completude de l'onglet
|
||||||
* l'onglet » : le prestataire est minimal et n'impose pas les six scalaires
|
* INCLUSE : a la validation complete de l'onglet, les six scalaires comptables
|
||||||
* comptables (spec M3 § 3.1).
|
* sont obligatoires (spec-front M3 § Onglet Comptabilite — aligne M1/M2).
|
||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
@@ -81,5 +81,58 @@ final class ProviderAccountingValidationTest extends AbstractProviderApiTestCase
|
|||||||
self::assertResponseStatusCodeSame(200);
|
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.
|
// violationsByPath() : helper mutualise dans AbstractProviderApiTestCase.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use App\Module\Technique\Domain\Entity\Provider;
|
|||||||
/**
|
/**
|
||||||
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire
|
* 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
|
* (M3, spec § 4.5 — ERP-135). Couvrent : normalisation contact (RG-3.11), RG-3.04
|
||||||
* (au moins un champ parmi prenom/nom/fonction/telephone/email), RG-3.05 (>= 1 site sur
|
* (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),
|
* 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`),
|
* 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
|
* RG-3.08 (DELETE dernier RIB sous LCR -> 409), DELETE contact libre au M3 (pas de
|
||||||
@@ -53,43 +53,60 @@ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-3.04 : un bloc Contact est valide des qu'AU MOINS UN champ est rempli parmi
|
* RG-3.04 (aligne M1/M2) : un bloc Contact exige le prenom OU le nom. Une
|
||||||
* prenom / nom / FONCTION / telephone / email (spec § RG-3.04, ligne 926). Ici
|
* Fonction seule (sans nom ni prenom) ne suffit plus -> 422 rattachee a firstName.
|
||||||
* seul jobTitle (Fonction) est fourni -> le bloc est valide -> 201.
|
|
||||||
*/
|
*/
|
||||||
public function testPostContactWithOnlyJobTitleReturns201(): void
|
public function testPostContactWithOnlyJobTitleReturns422(): void
|
||||||
{
|
{
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
$seed = $this->seedProvider('Contact JobTitle Only');
|
$seed = $this->seedProvider('Contact JobTitle Only');
|
||||||
|
|
||||||
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
|
|
||||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
|
||||||
'json' => ['jobTitle' => 'Directeur'],
|
|
||||||
])->toArray();
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
self::assertSame('Directeur', $data['jobTitle']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-3.04 : un bloc Contact TOTALEMENT vide (aucun champ du CHECK
|
|
||||||
* chk_provider_contact_name) est rejete avant la base -> 422 rattachee a
|
|
||||||
* firstName. Une Fonction vide (apres normalisation) ne suffit pas a valider.
|
|
||||||
*/
|
|
||||||
public function testPostContactCompletelyEmptyReturns422OnFirstNamePath(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedProvider('Contact No Field');
|
|
||||||
|
|
||||||
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
|
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
|
||||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||||
'json' => ['jobTitle' => ' '],
|
'json' => ['jobTitle' => 'Directeur'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
self::assertResponseStatusCodeSame(422);
|
||||||
self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false)));
|
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
|
public function testPostContactOnMissingProviderReturns404(): void
|
||||||
{
|
{
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Transport\Infrastructure\Console;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
|
use const JSON_THROW_ON_ERROR;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test fonctionnel de `app:qualimat:sync` via l'option --file (pas d'appel
|
||||||
|
* reseau) : verifie l'upsert normalise, le journal et le soft-delete.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class SyncQualimatCommandTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private Connection $connection;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
|
||||||
|
/** @var Connection $connection */
|
||||||
|
$connection = self::getContainer()->get('doctrine.dbal.default_connection');
|
||||||
|
$this->connection = $connection;
|
||||||
|
$this->purge();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->purge();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFirstSyncInsertsNormalizesAndLogs(): void
|
||||||
|
{
|
||||||
|
$tester = $this->runSync([
|
||||||
|
[
|
||||||
|
'Nom' => '2C TRANS',
|
||||||
|
'Societe' => '2C TRANS',
|
||||||
|
'Adresse' => '66 Impasse Mendi',
|
||||||
|
'CodePostal' => '65500',
|
||||||
|
'Ville' => 'VIC EN BIGORRE',
|
||||||
|
'Telephone_1' => '+33|0608890316',
|
||||||
|
'Siret' => '444 156 285 000 25',
|
||||||
|
'Validite' => '14/05/2027',
|
||||||
|
'Statut' => 'Audité',
|
||||||
|
'Departement' => '65 - Hautes-Pyrénées',
|
||||||
|
],
|
||||||
|
// Item sans SIRET : doit etre ignore (compte dans rows_skipped).
|
||||||
|
['Nom' => 'SANS SIRET', 'Siret' => null, 'Validite' => '01/01/2030', 'Statut' => 'Valide'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tester->assertCommandIsSuccessful();
|
||||||
|
|
||||||
|
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier'));
|
||||||
|
|
||||||
|
$row = $this->connection->fetchAssociative('SELECT * FROM qualimat_carrier');
|
||||||
|
self::assertNotFalse($row);
|
||||||
|
self::assertSame('44415628500025', $row['siret']);
|
||||||
|
self::assertSame('2C TRANS', $row['name']);
|
||||||
|
self::assertSame('2027-05-14', $row['validity_date']);
|
||||||
|
self::assertSame('+33|0608890316', $row['phone']);
|
||||||
|
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier WHERE is_active = TRUE'));
|
||||||
|
|
||||||
|
$log = $this->connection->fetchAssociative('SELECT * FROM qualimat_sync_log ORDER BY id DESC LIMIT 1');
|
||||||
|
self::assertNotFalse($log);
|
||||||
|
self::assertSame(2, (int) $log['rows_total']);
|
||||||
|
self::assertSame(1, (int) $log['rows_upserted']);
|
||||||
|
self::assertSame(1, (int) $log['rows_skipped']);
|
||||||
|
self::assertSame(0, (int) $log['rows_deactivated']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSecondSyncUpdatesAndSoftDeletesMissing(): void
|
||||||
|
{
|
||||||
|
$a = ['Nom' => 'A', 'Siret' => '111 111 111 00011', 'Validite' => '01/01/2030', 'Statut' => 'Audité'];
|
||||||
|
$b = ['Nom' => 'B', 'Siret' => '222 222 222 00022', 'Validite' => '01/01/2030', 'Statut' => 'Audité'];
|
||||||
|
|
||||||
|
$this->runSync([$a, $b])->assertCommandIsSuccessful();
|
||||||
|
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier WHERE is_active = TRUE'));
|
||||||
|
|
||||||
|
// 2e run sans B et avec A renomme : A est mis a jour, B est soft-delete.
|
||||||
|
$aRenamed = ['Nom' => 'A BIS', 'Siret' => '111 111 111 00011', 'Validite' => '02/02/2031', 'Statut' => 'Valide'];
|
||||||
|
$tester = $this->runSync([$aRenamed]);
|
||||||
|
$tester->assertCommandIsSuccessful();
|
||||||
|
|
||||||
|
// Toujours 2 lignes en base, mais une seule active.
|
||||||
|
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier'));
|
||||||
|
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier WHERE is_active = TRUE'));
|
||||||
|
self::assertSame(1, $this->countRows("SELECT COUNT(*) FROM qualimat_carrier WHERE siret = '22222222200022' AND is_active = FALSE"));
|
||||||
|
|
||||||
|
// A a bien ete mis a jour (nom + statut + date).
|
||||||
|
$a = $this->connection->fetchAssociative("SELECT * FROM qualimat_carrier WHERE siret = '11111111100011'");
|
||||||
|
self::assertNotFalse($a);
|
||||||
|
self::assertSame('A BIS', $a['name']);
|
||||||
|
self::assertSame('Valide', $a['status']);
|
||||||
|
self::assertSame('2031-02-02', $a['validity_date']);
|
||||||
|
|
||||||
|
$log = $this->connection->fetchAssociative('SELECT * FROM qualimat_sync_log ORDER BY id DESC LIMIT 1');
|
||||||
|
self::assertNotFalse($log);
|
||||||
|
self::assertSame(1, (int) $log['rows_upserted']);
|
||||||
|
self::assertSame(1, (int) $log['rows_deactivated']);
|
||||||
|
self::assertSame(0, (int) $log['rows_skipped']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmptySourceAbortsWithoutMassDeactivation(): void
|
||||||
|
{
|
||||||
|
// Premier run : 2 transporteurs actifs.
|
||||||
|
$a = ['Nom' => 'A', 'Siret' => '111 111 111 00011', 'Validite' => '01/01/2030', 'Statut' => 'Audité'];
|
||||||
|
$b = ['Nom' => 'B', 'Siret' => '222 222 222 00022', 'Validite' => '01/01/2030', 'Statut' => 'Audité'];
|
||||||
|
$this->runSync([$a, $b])->assertCommandIsSuccessful();
|
||||||
|
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier WHERE is_active = TRUE'));
|
||||||
|
|
||||||
|
// Source ne contenant que des items inexploitables (zero ligne mappee) :
|
||||||
|
// la commande doit ECHOUER sans toucher le referentiel (pas de soft-delete
|
||||||
|
// de masse) et sans journaliser de run.
|
||||||
|
$logsBefore = $this->countRows('SELECT COUNT(*) FROM qualimat_sync_log');
|
||||||
|
$tester = $this->runSync([
|
||||||
|
['Nom' => 'SANS SIRET 1', 'Siret' => null],
|
||||||
|
['Nom' => 'SANS SIRET 2', 'Siret' => ' '],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(Command::FAILURE, $tester->getStatusCode());
|
||||||
|
// Les 2 transporteurs restent ACTIFS (aucune desactivation de masse).
|
||||||
|
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier WHERE is_active = TRUE'));
|
||||||
|
// Aucun journal supplementaire (abandon avant la transaction).
|
||||||
|
self::assertSame($logsBefore, $this->countRows('SELECT COUNT(*) FROM qualimat_sync_log'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $items
|
||||||
|
*/
|
||||||
|
private function runSync(array $items): CommandTester
|
||||||
|
{
|
||||||
|
$path = tempnam(sys_get_temp_dir(), 'qualimat_').'.json';
|
||||||
|
file_put_contents($path, json_encode($items, JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
$application = new Application(self::$kernel);
|
||||||
|
$tester = new CommandTester($application->find('app:qualimat:sync'));
|
||||||
|
$tester->execute(['--file' => $path]);
|
||||||
|
|
||||||
|
@unlink($path);
|
||||||
|
|
||||||
|
return $tester;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countRows(string $sql): int
|
||||||
|
{
|
||||||
|
return (int) $this->connection->fetchOne($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function purge(): void
|
||||||
|
{
|
||||||
|
$this->connection->executeStatement('DELETE FROM qualimat_carrier');
|
||||||
|
$this->connection->executeStatement('DELETE FROM qualimat_sync_log');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Shared\Api;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Entity\UploadedDocument;
|
||||||
|
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests fonctionnels de l'endpoint d'upload generique (ERP-154).
|
||||||
|
*
|
||||||
|
* Couvre :
|
||||||
|
* - POST multipart d'un PDF valide -> 201, IRI renvoyee, ligne persistee,
|
||||||
|
* checksum sha256 calcule cote serveur ;
|
||||||
|
* - POST d'un MIME hors whitelist (text/plain) -> 422 ;
|
||||||
|
* - POST sans fichier -> 422 ;
|
||||||
|
* - POST anonyme -> 401 (acces /api protege globalement).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class UploadedDocumentApiTest extends AbstractApiTestCase
|
||||||
|
{
|
||||||
|
private const string ENDPOINT = '/api/uploaded_documents';
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $tempFiles = [];
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach ($this->tempFiles as $path) {
|
||||||
|
if (is_file($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUploadValidPdfReturnsIriAndPersistsRowWithChecksum(): void
|
||||||
|
{
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$content = $this->minimalPdf();
|
||||||
|
$file = $this->makeUploadedFile($content, 'facture.pdf');
|
||||||
|
|
||||||
|
$response = $client->request('POST', self::ENDPOINT, [
|
||||||
|
'headers' => ['Accept' => 'application/ld+json'],
|
||||||
|
'extra' => ['files' => ['file' => $file]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
|
||||||
|
$data = $response->toArray();
|
||||||
|
|
||||||
|
self::assertArrayHasKey('@id', $data);
|
||||||
|
self::assertStringStartsWith(self::ENDPOINT.'/', $data['@id']);
|
||||||
|
self::assertSame('facture.pdf', $data['originalFilename']);
|
||||||
|
self::assertSame('application/pdf', $data['mimeType']);
|
||||||
|
self::assertSame(\strlen($content), $data['sizeBytes']);
|
||||||
|
self::assertSame(hash('sha256', $content), $data['checksum']);
|
||||||
|
self::assertSame(64, \strlen($data['checksum']));
|
||||||
|
|
||||||
|
// La ligne est bien persistee et relisible via le repository.
|
||||||
|
$id = $data['id'];
|
||||||
|
$document = $this->getEm()->getRepository(UploadedDocument::class)->find($id);
|
||||||
|
self::assertInstanceOf(UploadedDocument::class, $document);
|
||||||
|
self::assertSame(hash('sha256', $content), $document->getChecksum());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUploadDisallowedMimeTypeReturns422(): void
|
||||||
|
{
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$file = $this->makeUploadedFile('just some plain text content', 'note.txt');
|
||||||
|
|
||||||
|
$client->request('POST', self::ENDPOINT, [
|
||||||
|
'headers' => ['Accept' => 'application/ld+json'],
|
||||||
|
'extra' => ['files' => ['file' => $file]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUploadWithoutFileReturns422(): void
|
||||||
|
{
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
|
||||||
|
$client->request('POST', self::ENDPOINT, [
|
||||||
|
'headers' => ['Accept' => 'application/ld+json'],
|
||||||
|
'extra' => ['files' => []],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUploadAnonymousIsRejected(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$file = $this->makeUploadedFile($this->minimalPdf(), 'facture.pdf');
|
||||||
|
|
||||||
|
$client->request('POST', self::ENDPOINT, [
|
||||||
|
'headers' => ['Accept' => 'application/ld+json'],
|
||||||
|
'extra' => ['files' => ['file' => $file]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cree un UploadedFile en mode test (move() autorise hors contexte HTTP).
|
||||||
|
*/
|
||||||
|
private function makeUploadedFile(string $content, string $clientName): UploadedFile
|
||||||
|
{
|
||||||
|
$path = sys_get_temp_dir().'/erp154-api-'.bin2hex(random_bytes(4));
|
||||||
|
file_put_contents($path, $content);
|
||||||
|
$this->tempFiles[] = $path;
|
||||||
|
|
||||||
|
return new UploadedFile($path, $clientName, null, null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contenu PDF minimal valide (entete `%PDF-1.4` -> finfo `application/pdf`).
|
||||||
|
*/
|
||||||
|
private function minimalPdf(): string
|
||||||
|
{
|
||||||
|
return "%PDF-1.4\n"
|
||||||
|
."1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
|
||||||
|
."2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
|
||||||
|
."3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
|
||||||
|
."trailer<</Root 1 0 R/Size 4>>\n"
|
||||||
|
."%%EOF\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Shared\Infrastructure\Upload;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Exception\FileTooLargeException;
|
||||||
|
use App\Shared\Domain\Exception\UnsupportedMimeTypeException;
|
||||||
|
use App\Shared\Infrastructure\Upload\FileUploader;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Clock\MockClock;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests unitaires du service generique de televersement (ERP-154).
|
||||||
|
*
|
||||||
|
* Couvre : rejet d'un MIME hors whitelist, rejet d'un fichier trop volumineux,
|
||||||
|
* et le chemin nominal (checksum sha256 calcule, taille/MIME captures, fichier
|
||||||
|
* ecrit sous var/uploads/{yyyy}/{mm}/ avec horodatage de l'horloge injectee).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class FileUploaderTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $uploadBaseDir;
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $tempFiles = [];
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->uploadBaseDir = sys_get_temp_dir().'/erp154-uploads-'.bin2hex(random_bytes(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
// Nettoyage des fichiers sources et de l'arborescence de destination.
|
||||||
|
foreach ($this->tempFiles as $path) {
|
||||||
|
if (is_file($path)) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->removeDirectory($this->uploadBaseDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRejectsMimeTypeOutsideWhitelist(): void
|
||||||
|
{
|
||||||
|
$uploader = $this->createUploader();
|
||||||
|
$file = $this->makeUploadedFile('hello world plain text', 'note.txt');
|
||||||
|
|
||||||
|
$this->expectException(UnsupportedMimeTypeException::class);
|
||||||
|
|
||||||
|
$uploader->upload($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRejectsFileLargerThanMaxSize(): void
|
||||||
|
{
|
||||||
|
$uploader = $this->createUploader();
|
||||||
|
|
||||||
|
// Contenu PDF valide mais artificiellement gonfle au-dela de la borne.
|
||||||
|
$content = "%PDF-1.4\n".str_repeat('A', FileUploader::MAX_SIZE_BYTES + 1);
|
||||||
|
$file = $this->makeUploadedFile($content, 'huge.pdf');
|
||||||
|
|
||||||
|
$this->expectException(FileTooLargeException::class);
|
||||||
|
|
||||||
|
$uploader->upload($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStoresPdfAndComputesSha256Checksum(): void
|
||||||
|
{
|
||||||
|
$content = $this->minimalPdf();
|
||||||
|
$clock = new MockClock(new DateTimeImmutable('2026-06-15 10:00:00'));
|
||||||
|
$uploader = $this->createUploader($clock);
|
||||||
|
$file = $this->makeUploadedFile($content, 'facture.pdf');
|
||||||
|
|
||||||
|
$document = $uploader->upload($file);
|
||||||
|
|
||||||
|
self::assertSame('facture.pdf', $document->getOriginalFilename());
|
||||||
|
self::assertSame('application/pdf', $document->getMimeType());
|
||||||
|
self::assertSame(\strlen($content), $document->getSizeBytes());
|
||||||
|
self::assertSame(hash('sha256', $content), $document->getChecksum());
|
||||||
|
self::assertSame(64, \strlen($document->getChecksum()));
|
||||||
|
|
||||||
|
// Chemin relatif date selon l'horloge injectee (2026/06).
|
||||||
|
self::assertStringStartsWith('2026/06/', $document->getStoredPath());
|
||||||
|
self::assertFileExists($this->uploadBaseDir.'/'.$document->getStoredPath());
|
||||||
|
|
||||||
|
// Le fichier ecrit a bien le contenu d'origine (checksum coherent).
|
||||||
|
self::assertSame(
|
||||||
|
$document->getChecksum(),
|
||||||
|
hash_file('sha256', $this->uploadBaseDir.'/'.$document->getStoredPath()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRemoveDeletesStoredFile(): void
|
||||||
|
{
|
||||||
|
$uploader = $this->createUploader();
|
||||||
|
$file = $this->makeUploadedFile($this->minimalPdf(), 'facture.pdf');
|
||||||
|
$document = $uploader->upload($file);
|
||||||
|
$storedPath = $this->uploadBaseDir.'/'.$document->getStoredPath();
|
||||||
|
self::assertFileExists($storedPath);
|
||||||
|
|
||||||
|
// Compensation : remove() efface le fichier physique...
|
||||||
|
$uploader->remove($document);
|
||||||
|
self::assertFileDoesNotExist($storedPath);
|
||||||
|
|
||||||
|
// ...et reste silencieux si on le rappelle alors que le fichier a disparu.
|
||||||
|
$uploader->remove($document);
|
||||||
|
self::assertFileDoesNotExist($storedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createUploader(?MockClock $clock = null): FileUploader
|
||||||
|
{
|
||||||
|
return new FileUploader($this->uploadBaseDir, $clock ?? new MockClock());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cree un UploadedFile en mode test (move() autorise hors contexte HTTP).
|
||||||
|
*/
|
||||||
|
private function makeUploadedFile(string $content, string $clientName): UploadedFile
|
||||||
|
{
|
||||||
|
$path = sys_get_temp_dir().'/erp154-src-'.bin2hex(random_bytes(4));
|
||||||
|
file_put_contents($path, $content);
|
||||||
|
$this->tempFiles[] = $path;
|
||||||
|
|
||||||
|
// Le 5e argument `test: true` court-circuite move_uploaded_file().
|
||||||
|
return new UploadedFile($path, $clientName, null, null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contenu PDF minimal valide — l'entete `%PDF-1.4` suffit a faire detecter
|
||||||
|
* `application/pdf` par finfo (getMimeType server-side).
|
||||||
|
*/
|
||||||
|
private function minimalPdf(): string
|
||||||
|
{
|
||||||
|
return "%PDF-1.4\n"
|
||||||
|
."1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
|
||||||
|
."2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
|
||||||
|
."3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
|
||||||
|
."trailer<</Root 1 0 R/Size 4>>\n"
|
||||||
|
."%%EOF\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removeDirectory(string $dir): void
|
||||||
|
{
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = scandir($dir) ?: [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ('.' === $item || '..' === $item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$path = $dir.'/'.$item;
|
||||||
|
is_dir($path) ? $this->removeDirectory($path) : @unlink($path);
|
||||||
|
}
|
||||||
|
@rmdir($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user