Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9313dbec8 | |||
| e607cccf08 | |||
| 8b8fb8c2aa | |||
| f9fec3e908 | |||
| 4f8ed075b6 | |||
| 1e783bd753 | |||
| 9f4f45f761 | |||
| e99747ac72 | |||
| 36edd11854 | |||
| 45cb5c834c | |||
| 2689b85ebe | |||
| f4bbc79550 | |||
| f057866e75 | |||
| 19fdb50cec | |||
| 368bb50ffb | |||
| 6a83adc00a | |||
| c76c447aa2 | |||
| 19ac8833eb | |||
| c25c33116d |
+2
-2
@@ -24,6 +24,7 @@
|
||||
"symfony/expression-language": "8.0.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/http-client": "8.0.*",
|
||||
"symfony/intl": "8.0.*",
|
||||
"symfony/mime": "8.0.*",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
@@ -95,7 +96,6 @@
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||
"friendsofphp/php-cs-fixer": "^3.94",
|
||||
"phpunit/phpunit": "^13.0",
|
||||
"symfony/browser-kit": "8.0.*",
|
||||
"symfony/http-client": "8.0.*"
|
||||
"symfony/browser-kit": "8.0.*"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+175
-175
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "2dc5db01e7f5d6aecd5956749b21a092",
|
||||
"content-hash": "b029c1484227c926d39dfd3ae5cb0699",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -5412,6 +5412,180 @@
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v8.0.13",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "c7f40f9103233630167c25c9a4570acf805fdade"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/c7f40f9103233630167c25c9a4570acf805fdade",
|
||||
"reference": "c7f40f9103233630167c25c9a4570acf805fdade",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"amphp/amp": "<3",
|
||||
"php-http/discovery": "<1.15"
|
||||
},
|
||||
"provide": {
|
||||
"php-http/async-client-implementation": "*",
|
||||
"php-http/client-implementation": "*",
|
||||
"psr/http-client-implementation": "1.0",
|
||||
"symfony/http-client-implementation": "3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"amphp/http-client": "^5.3.2",
|
||||
"amphp/http-tunnel": "^2.0",
|
||||
"guzzlehttp/promises": "^1.4|^2.0",
|
||||
"nyholm/psr7": "^1.0",
|
||||
"php-http/httplug": "^1.0|^2.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/cache": "^7.4|^8.0",
|
||||
"symfony/dependency-injection": "^7.4|^8.0",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/messenger": "^7.4|^8.0",
|
||||
"symfony/process": "^7.4|^8.0",
|
||||
"symfony/rate-limiter": "^7.4|^8.0",
|
||||
"symfony/stopwatch": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client/tree/v8.0.13"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-24T09:58:02+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client-contracts",
|
||||
"version": "v3.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client-contracts.git",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/contracts",
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.6-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Contracts\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Test/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Generic abstractions related to HTTP clients",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"abstractions",
|
||||
"contracts",
|
||||
"decoupling",
|
||||
"interfaces",
|
||||
"interoperability",
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-04-29T11:18:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-foundation",
|
||||
"version": "v8.0.8",
|
||||
@@ -11785,180 +11959,6 @@
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v8.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e",
|
||||
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"amphp/amp": "<3",
|
||||
"php-http/discovery": "<1.15"
|
||||
},
|
||||
"provide": {
|
||||
"php-http/async-client-implementation": "*",
|
||||
"php-http/client-implementation": "*",
|
||||
"psr/http-client-implementation": "1.0",
|
||||
"symfony/http-client-implementation": "3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"amphp/http-client": "^5.3.2",
|
||||
"amphp/http-tunnel": "^2.0",
|
||||
"guzzlehttp/promises": "^1.4|^2.0",
|
||||
"nyholm/psr7": "^1.0",
|
||||
"php-http/httplug": "^1.0|^2.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/cache": "^7.4|^8.0",
|
||||
"symfony/dependency-injection": "^7.4|^8.0",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/messenger": "^7.4|^8.0",
|
||||
"symfony/process": "^7.4|^8.0",
|
||||
"symfony/rate-limiter": "^7.4|^8.0",
|
||||
"symfony/stopwatch": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client/tree/v8.0.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client-contracts",
|
||||
"version": "v3.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client-contracts.git",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/contracts",
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.6-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Contracts\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Test/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Generic abstractions related to HTTP clients",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"abstractions",
|
||||
"contracts",
|
||||
"decoupling",
|
||||
"interfaces",
|
||||
"interoperability",
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-04-29T11:18:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/process",
|
||||
"version": "v8.0.8",
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Module\Commercial\CommercialModule;
|
||||
use App\Module\Core\CoreModule;
|
||||
use App\Module\Sites\SitesModule;
|
||||
use App\Module\Technique\TechniqueModule;
|
||||
use App\Module\Transport\TransportModule;
|
||||
|
||||
return [
|
||||
CoreModule::class,
|
||||
@@ -13,4 +14,5 @@ return [
|
||||
SitesModule::class,
|
||||
CatalogModule::class,
|
||||
TechniqueModule::class,
|
||||
TransportModule::class,
|
||||
];
|
||||
|
||||
@@ -12,6 +12,9 @@ api_platform:
|
||||
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
|
||||
# en dehors de Domain/Entity : AuditLogResource, etc.
|
||||
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
|
||||
# Entites techniques partagees portant un #[ApiResource]
|
||||
# (UploadedDocument — infra upload generique ERP-154).
|
||||
- '%kernel.project_dir%/src/Shared/Domain/Entity'
|
||||
formats:
|
||||
jsonld: ['application/ld+json']
|
||||
json: ['application/json']
|
||||
|
||||
@@ -8,16 +8,29 @@ doctrine:
|
||||
default:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
# Exclut `audit_log` de toute operation de comparaison de schema
|
||||
# (doctrine:schema:update, schema:validate, diff de migrations...).
|
||||
# Cette table n'a volontairement aucune entite mappee : elle est
|
||||
# append-only via DBAL brut (AuditLogWriter) pour eviter la
|
||||
# recursion du listener Doctrine. Sans ce filtre, schema:update
|
||||
# la considere comme "orpheline" et genere un `DROP TABLE
|
||||
# audit_log` qui casse la base de test apres chaque
|
||||
# `make test-db-setup`. La creation / suppression de la table
|
||||
# reste pilotee par les migrations (cf. Version20260420202749).
|
||||
schema_filter: '~^(?!audit_log$).+~'
|
||||
# Exclut certaines tables de toute operation de comparaison de
|
||||
# schema (doctrine:schema:update, schema:validate, diff de
|
||||
# migrations...). Ces tables n'ont volontairement aucune entite
|
||||
# mappee :
|
||||
# - `audit_log` : append-only via DBAL brut (AuditLogWriter) pour
|
||||
# eviter la recursion du listener Doctrine.
|
||||
# - `qualimat_sync_log` : journal de synchro transporteurs
|
||||
# QUALIMAT, ecrit en DBAL brut par `app:qualimat:sync`, hors ORM.
|
||||
# NB : `qualimat_carrier` n'est PLUS filtree depuis M4 (ERP-155) :
|
||||
# elle est desormais mappee en LECTURE SEULE par l'entite
|
||||
# App\Module\Transport\Domain\Entity\QualimatCarrier (cible de la
|
||||
# FK editable carrier.qualimat_carrier_id). Son mapping reproduit
|
||||
# a l'identique le DDL de la migration ERP-39 (unique siret, index
|
||||
# is_active, TIMESTAMP(6)) -> schema:update reste un no-op.
|
||||
# - `idtf_product` / `idtf_sync_log` : referentiel codes IDTF
|
||||
# synchronise en DBAL brut par `app:idtf:sync`, hors ORM.
|
||||
# Sans ce filtre, schema:update les considere comme "orphelines" et
|
||||
# genere un `DROP TABLE` qui casse la base de test apres chaque
|
||||
# `make test-db-setup` (la migration les a creees, schema:update les
|
||||
# supprime juste apres). Creation / suppression restent pilotees par
|
||||
# les migrations (audit_log : Version20260420202749 ; qualimat :
|
||||
# Version20260612150000 ; idtf : Version20260612160000).
|
||||
schema_filter: '~^(?!(?:audit_log|qualimat_sync_log|idtf_product|idtf_sync_log)$).+~'
|
||||
audit:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
orm:
|
||||
@@ -41,7 +54,26 @@ doctrine:
|
||||
# Permet au module Commercial de referencer une Category via le contrat
|
||||
# Shared sans importer la classe concrete du module Catalog (regle n°1).
|
||||
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
|
||||
# Cibles des ManyToOne de CarrierPrice (M4 Transport, onglet Prix) :
|
||||
# permet au module Transport de referencer Client / Supplier et leurs
|
||||
# adresses (M1/M2 Commercial) via des contrats Shared sans importer les
|
||||
# classes concretes (regle n°1). L'embed JSON passe par les read-groups
|
||||
# des entites concretes (client:read / supplier:read / ...).
|
||||
App\Shared\Domain\Contract\ClientInterface: App\Module\Commercial\Domain\Entity\Client
|
||||
App\Shared\Domain\Contract\ClientAddressInterface: App\Module\Commercial\Domain\Entity\ClientAddress
|
||||
App\Shared\Domain\Contract\SupplierInterface: App\Module\Commercial\Domain\Entity\Supplier
|
||||
App\Shared\Domain\Contract\SupplierAddressInterface: App\Module\Commercial\Domain\Entity\SupplierAddress
|
||||
mappings:
|
||||
# Mapping des entites techniques partagees (src/Shared/Domain/Entity).
|
||||
# Premier occupant : UploadedDocument (infra upload generique ERP-154).
|
||||
# Necessaire car les entites Shared ne sont pas couvertes par
|
||||
# l'auto_mapping (qui ne cible que les bundles).
|
||||
Shared:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Shared/Domain/Entity'
|
||||
prefix: 'App\Shared\Domain\Entity'
|
||||
alias: Shared
|
||||
Core:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
@@ -90,6 +122,17 @@ doctrine:
|
||||
dir: '%kernel.project_dir%/src/Module/Technique/Domain/Entity'
|
||||
prefix: 'App\Module\Technique\Domain\Entity'
|
||||
alias: Technique
|
||||
# Mapping inconditionnel du module Transport (meme logique que Technique) :
|
||||
# les tables transporteurs (carrier + sous-collections) creees par la
|
||||
# migration M4 (Version20260615150000) et le mapping lecture-seule de
|
||||
# qualimat_carrier (referentiel ERP-39) doivent etre connus de l'ORM.
|
||||
# L'activation fonctionnelle passe par config/modules.php.
|
||||
Transport:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Transport/Domain/Entity'
|
||||
prefix: 'App\Module\Transport\Domain\Entity'
|
||||
alias: Transport
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
|
||||
@@ -2,4 +2,5 @@ doctrine_migrations:
|
||||
migrations_paths:
|
||||
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||
'App\Module\Core\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Core/Infrastructure/Doctrine/Migrations'
|
||||
'App\Module\Transport\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Transport/Infrastructure/Doctrine/Migrations'
|
||||
enable_profiler: false
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Active le composant HTTP Client (symfony/http-client) et enregistre
|
||||
# l'autowiring de HttpClientInterface. Utilise par les commandes de
|
||||
# synchronisation de referentiels externes (QUALIMAT, IDTF...).
|
||||
#
|
||||
# User-Agent navigateur neutre : les sources (qualimat.org sous WordPress/WAF,
|
||||
# icrt-idtf.com) filtrent souvent les UA de bibliotheque/vides ; un UA de type
|
||||
# navigateur evite les blocages anti-bot sans reveler l'application.
|
||||
framework:
|
||||
http_client:
|
||||
default_options:
|
||||
timeout: 30
|
||||
headers:
|
||||
User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
||||
@@ -78,6 +78,23 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Transport" (M4, ERP-153) : pole logistique, porte le repertoire
|
||||
// transporteurs. L'item est gate par `transport.carriers.view` ; la section
|
||||
// disparait automatiquement (SidebarProvider) si le module `transport` est
|
||||
// desactive ou si l'user n'a pas la permission (Compta / Usine).
|
||||
[
|
||||
'label' => 'sidebar.transport.section',
|
||||
'icon' => 'mdi:truck-outline',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.transport.carriers',
|
||||
'to' => '/carriers',
|
||||
'icon' => 'mdi:truck-outline',
|
||||
'module' => 'transport',
|
||||
'permission' => 'transport.carriers.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Administration" : regroupe toutes les pages de configuration
|
||||
// applicative (RBAC, users, sites, audit log).
|
||||
//
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.118'
|
||||
app.version: '0.1.126'
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# M4 — Plan maître worktrees (back, Matthieu)
|
||||
|
||||
> **Rôle de ce fichier** : vue d'ensemble que la *conversation maître* tient à jour.
|
||||
> Chaque worktree = une conversation Claude isolée + une branche + une PR vers `develop`.
|
||||
> Les prompts à coller sont dans `WT*.md`.
|
||||
|
||||
## Principe
|
||||
|
||||
- 1 worktree = 1 branche partant de `origin/develop` (à jour des deps).
|
||||
- 1 ticket = 1 PR atomique vers **`develop`** (jamais `main`).
|
||||
- Commit autorisé sur la branche du worktree (ces prompts SONT la demande explicite) ;
|
||||
`git commit --no-verify` OK si `make test` est déjà vert (le hook relance toute la suite).
|
||||
- **Chaque worktree ouvre SA PR** vers `develop` en fin de tâche (cf. bloc PR ci-dessous).
|
||||
|
||||
## Bloc PR standard (repris dans chaque prompt)
|
||||
|
||||
```bash
|
||||
git push -u origin <branche>
|
||||
tea pr create --base develop --head <branche> \
|
||||
--title "<type>(<scope>) : <titre>" \
|
||||
--description "Résumé + lien ticket Lesstime ERP-XXX"
|
||||
```
|
||||
Puis **labelliser la PR via l'API Gitea** (tea ne pose pas les labels en CLI — `gitea.malio.fr`).
|
||||
Cible **`develop`**, jamais `main`. **Aucune mention de Claude/IA** dans titre ou description.
|
||||
|
||||
## Vagues & ordre de merge
|
||||
|
||||
```
|
||||
VAGUE 0 (en parallèle, dès maintenant)
|
||||
WT1 1.2 upload Shared base: origin/develop ──┐
|
||||
WT2 1.1 RBAC + sidebar base: origin/develop (≥ERP-150) ──┤ indépendants
|
||||
│
|
||||
VAGUE 1 (critique, séquentiel) │
|
||||
WT3 1.3 migration + 1.5 entités/resource/provider + i18n audit
|
||||
base: origin/develop APRÈS merge WT1 (FK uploaded_document)
|
||||
⭐ livre le CONTRAT JSON liste+détail → débloque le front (Tristan)
|
||||
|
||||
VAGUE 2 (fan-out, tous en parallèle dès WT3 mergé)
|
||||
WT4 1.6 processor base: develop ≥ WT3
|
||||
WT5 1.4 qualimat endpoint base: develop ≥ WT2 (perm) + ERP-39 (indépendant de WT3)
|
||||
WT6 1.7 adresses base: develop ≥ WT3
|
||||
WT7 1.8 contacts base: develop ≥ WT3
|
||||
WT8 1.9 prix base: develop ≥ WT3
|
||||
WT9 1.10 export XLSX base: develop ≥ WT3
|
||||
|
||||
VAGUE 3 (final)
|
||||
WT10 1.11 tests + fixtures + contrat base: develop ≥ TOUT
|
||||
```
|
||||
|
||||
**Parallélisme réel** : 2 worktrees en V0, puis 1 goulot (WT3), puis **jusqu'à 6 en V2**, puis 1 (WT10).
|
||||
|
||||
## Règle anti-conflit worktree (IMPORTANT)
|
||||
|
||||
Pour que WT4→WT9 tournent en parallèle sans conflit de merge :
|
||||
|
||||
| Fichier partagé | Qui le touche | Les autres |
|
||||
|---|---|---|
|
||||
| `CarrierFixtures` | **WT10 uniquement** | interdit (WT3 met un fixture minimal, WT6-9 n'y touchent pas) |
|
||||
| Entité `Carrier` (ApiResource) | **WT3** crée, **WT4** ajoute le Processor | WT6-9 créent des **resources/processors dédiés** par sous-entité, ne modifient pas `Carrier` |
|
||||
| `ColumnCommentsCatalog` | WT1 (`uploaded_document`), WT3 (`carrier*`) | personne d'autre |
|
||||
| `fr.json` (clés audit) | **WT3** (clés `audit.entity.transport_*`) | personne d'autre côté back |
|
||||
| `migrations/` | WT1 puis WT3 (ordre timestamp) | aucune autre migration |
|
||||
|
||||
## Mode retenu : STACK séquentiel, SANS worktree (repo principal)
|
||||
|
||||
Matthieu empile les MR, un ticket à la fois, **directement dans `/home/matthieu/dev_malio/Starseed`** (pas de worktree).
|
||||
- **Ignorer les blocs `git worktree add` des `WT*.md`** → remplacés par une branche normale :
|
||||
```bash
|
||||
git fetch origin
|
||||
git checkout -b feat/erp-XXX-... origin/<branche-précédente>
|
||||
```
|
||||
- **WT1 hors pile** (déjà mergé). Pile M4 — chaque branche basée sur la précédente :
|
||||
`WT2 → WT3 → WT4 → WT5 → WT6 → WT7 → WT8 → WT9 → WT10`
|
||||
- PR de chaque maillon : `--base <branche-précédente>` (bas de pile WT2 = `develop`). Au merge, les MR du dessus se recible auto.
|
||||
- Docker tourne sur le repo principal → `make test`/`php-cs-fixer` OK sans rebind (le piège worktree-vs-mount ne s'applique plus).
|
||||
- Worktrees créés pour WT1/WT2 à nettoyer : `git worktree remove ../sb-erp154-upload ../sb-erp153-rbac`.
|
||||
- Garder les MR basses propres ; merger dans l'ordre.
|
||||
|
||||
## Suivi (tenu par la conv maître)
|
||||
|
||||
| WT | Ticket | ERP | État | PR | Notes |
|
||||
|----|--------|-----|------|----|----|
|
||||
| WT1 | 1.2 upload | 154 | ✅ MERGÉ | #108 | migration `Version20260615130000` |
|
||||
| WT2 | 1.1 RBAC | 153 | ✅ PR ouverte | #111 | bas de pile (cible develop) |
|
||||
| WT3 | 1.3+1.5 | 155+157 | ▶️ À LANCER | — | stack sur `feat/erp-153-rbac` ; gate contrat front |
|
||||
| WT4 | 1.6 proc | 158 | ⛔ bloqué par WT3 | — | |
|
||||
| WT5 | 1.4 qualimat | 156 | ⛔ bloqué par WT2+ERP-39 | — | |
|
||||
| WT6 | 1.7 adresses | 159 | ⛔ bloqué par WT3 | — | |
|
||||
| WT7 | 1.8 contacts | 160 | ⛔ bloqué par WT3 | — | |
|
||||
| WT8 | 1.9 prix | 161 | ⛔ bloqué par WT3 | — | |
|
||||
| WT9 | 1.10 export | 162 | ⛔ bloqué par WT3 | — | |
|
||||
| WT10 | 1.11 tests | 163 | ⛔ bloqué par tout | — | |
|
||||
|
||||
## Cadre commun à tous les prompts (rappels projet)
|
||||
|
||||
- Carrier vit dans `src/Module/Transport/` (créé par ERP-150). **Miroir = `src/Module/Commercial/`** (Supplier).
|
||||
- Tests sous `tests/Module/Transport/Api/` (miroir `tests/Module/Commercial/Api/`).
|
||||
- `declare(strict_types=1);` partout ; commentaires **FR**, code EN.
|
||||
- `make test` + `make php-cs-fixer-allow-risky` avant de dire « fini ».
|
||||
- Ne jamais mentionner Claude/IA dans commit/PR.
|
||||
@@ -0,0 +1,44 @@
|
||||
# WT1 — Infra upload générique `Shared` (ticket 1.2 / ERP-154)
|
||||
|
||||
> Créer le worktree puis lancer Claude dedans :
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp154-upload -b feat/erp-154-upload origin/develop
|
||||
> cd ../sb-erp154-upload && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` (aucune dépendance — peut démarrer tout de suite, même avant le merge du socle Transport).
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Tu travailles sur le projet Starseed (modular monolith DDD, Symfony 8 / API Platform 4). Lis `CLAUDE.md` et `.claude/rules/backend.md` avant de coder. Charge le skill `backend-entity-conventions`.
|
||||
|
||||
**Mission** : poser une infra d'upload de fichiers **générique et réutilisable** dans `src/Shared/` (la « Décharge » du M4 en sera le 1er consommateur, mais ce ticket ne touche PAS au module Transport).
|
||||
|
||||
**Spec** : `docs/specs/M4-transporteurs/spec-back.md § 2.7`.
|
||||
|
||||
**À livrer** :
|
||||
1. Table `uploaded_document` (migration namespace racine `DoctrineMigrations` dans `migrations/`, postérieure à la dernière présente — vérifie `ls migrations/`). Colonnes : `id`, `original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum`, `created_at`, `created_by`.
|
||||
2. Service `Shared\Infrastructure\Upload\FileUploader` :
|
||||
- validation MIME **server-side via `$file->getMimeType()`** (JAMAIS `getClientMimeType()`),
|
||||
- whitelist MIME explicite (PDF + images),
|
||||
- bornage taille, checksum sha256, écriture disque `var/uploads/{yyyy}/{mm}/`.
|
||||
3. Endpoint `POST /api/uploaded_documents` (multipart) → renvoie l'IRI. MIME hors whitelist → **422**.
|
||||
|
||||
**Gardes-fous (cassent `make test` sinon)** :
|
||||
- **`COMMENT ON COLUMN` sur TOUTES les colonnes** de `uploaded_document` (FR, ≤200 car., règle n°12) ET ajoute le bloc `'uploaded_document' => [...]` dans `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php` — sinon `make test-db-setup` drope les COMMENT et `ColumnsHaveSqlCommentTest` casse.
|
||||
- Pagination : si tu exposes une `GetCollection`, elle reste paginée (`CollectionsArePaginatedTest`).
|
||||
|
||||
**Scope STRICT** : uniquement `src/Shared/` + migration + catalog. Ne crée AUCUN fichier sous `src/Module/Transport/`. Pas d'antivirus/S3/purge (hors périmètre, § 9).
|
||||
|
||||
**Tests à écrire** (PHPUnit) : MIME hors whitelist → 422 ; MIME valide → IRI + ligne persistée + checksum calculé.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky` propre. Commit (`--no-verify` OK si `make test` déjà vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-154-upload
|
||||
tea pr create --base develop --head feat/erp-154-upload \
|
||||
--title "feat(shared) : infra upload générique (ERP-154)" \
|
||||
--description "Table uploaded_document + FileUploader + endpoint POST. Ticket ERP-154."
|
||||
```
|
||||
Puis labellise la PR via l'API Gitea (tea ne pose pas les labels en CLI). Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,37 @@
|
||||
# WT10 — Tests PHPUnit + fixtures + contrat JSON (ticket 1.11 / ERP-163)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp163-tests -b feat/erp-163-carrier-tests origin/develop
|
||||
> cd ../sb-erp163-tests && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de TOUS les worktrees back** (WT1→WT9). C'est le filet final.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`, `.claude/rules/testing.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `tests/Module/Commercial/Api/Supplier*Test.php`.
|
||||
|
||||
**Mission** : couverture complète des RG + capture du contrat de sérialisation + fixtures consolidées. C'est le DoD back avant intégration front.
|
||||
|
||||
**Spec** : `spec-back.md § 4.0.bis / 8.1 / 8.4`.
|
||||
|
||||
**À livrer** :
|
||||
- Matrice **RG-4.01→4.14** couverte (§ 8.1) + RBAC par rôle (Compta/Usine → 403, Commerciale → 403 sur write, Admin → archive).
|
||||
- `CarrierSerializationContractTest` : capture JSON réel **liste + détail** ; `prices[].client`/`.supplier`/sites **embarqués** (pas IRI) ; `qualimatCarrier` embarqué ; `isArchived` présent. Colle les JSON dans `spec-back.md § 4.0.bis`.
|
||||
- Anti-N+1 liste ; pagination Hydra ; audit (`entity_type='Carrier'`) ; `AuditableEntitiesHaveI18nLabelTest` vert.
|
||||
- **`CarrierFixtures` idempotent (§ 8.4)** — c'est ICI que les fixtures complètes vivent : transporteur QUALIMAT (validité passée → RG-4.04), AUTRE+décharge, affrété, LIOT, complet (contacts/adresses/prix CLIENT+FOURNISSEUR), 1 archivé.
|
||||
|
||||
**Piège CI (mémoire projet)** : la CI tourne `APP_DEBUG=0`. Les tests de **comptage de requêtes (anti-N+1)** passent en local mais cassent en CI (DoctrineDataHolder absent) → vérifie/active `profiling: true` dans la config Doctrine de l'environnement `test`. Sans ça le test anti-N+1 sera rouge en CI.
|
||||
|
||||
**Scope** : tests + `CarrierFixtures` + remplissage § 4.0.bis. Tu peux ajuster un test cassé hérité d'un autre WT mais signale-le à la conv maître (ne masque pas un vrai bug).
|
||||
|
||||
**Fini quand** : `make test` **intégralement vert** + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-163-carrier-tests
|
||||
tea pr create --base develop --head feat/erp-163-carrier-tests \
|
||||
--title "test(transport) : couverture RG-4.01→4.14 + contrat + fixtures (ERP-163)" \
|
||||
--description "Matrice RG + CarrierSerializationContractTest + CarrierFixtures + § 4.0.bis. Ticket ERP-163."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,45 @@
|
||||
# WT2 — Permissions `transport.carriers.*` + sidebar (ticket 1.1 / ERP-153)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp153-rbac -b feat/erp-153-rbac origin/develop
|
||||
> cd ../sb-erp153-rbac && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge d'ERP-150** (le module `Transport` doit exister). Vérifie : `ls src/Module/Transport/`.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (modular monolith DDD). Lis `CLAUDE.md`, `.claude/rules/architecture.md` et `.claude/rules/testing.md` avant de coder.
|
||||
|
||||
**Mission** : poser le socle RBAC du module Transport et son entrée de menu. `TransportModule::permissions()` renvoie `[]` aujourd'hui.
|
||||
|
||||
**Spec** : `spec-back.md § 5` + `spec-front.md § Accès`.
|
||||
|
||||
**À livrer** :
|
||||
1. `TransportModule::permissions()` déclare `transport.carriers.view`, `transport.carriers.manage`, `transport.carriers.archive`. `app:sync-permissions` les enregistre.
|
||||
2. **Matrice § 5.2** : Admin (view+manage+archive), Bureau (view+manage), Commerciale (view), Compta + Usine (**aucune**).
|
||||
3. **RÈGLE ABSOLUE n°8 — les 3 sources RBAC dans le MÊME commit** :
|
||||
- `config/sidebar.php` : section « Transport » + item `/carriers` + `permission: transport.carriers.view`,
|
||||
- `frontend/tests/e2e/_fixtures/personas.ts` : ajuster `permissions` + `expectedAdminLinks` des personas existants,
|
||||
- `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` : miroir back des mêmes personas.
|
||||
4. Item sidebar masqué pour Compta/Usine ; visible Admin/Bureau/Commerciale.
|
||||
|
||||
**Pièges** :
|
||||
- Ne touche QUE le RBAC/sidebar — pas d'entité, pas de migration.
|
||||
- Toute modif d'une seule des 3 sources sans les 2 autres = drift / test cassé.
|
||||
- Section « Transport » vs « Logistique » : prends « Transport » (cosmétique, alignable plus tard).
|
||||
|
||||
**Tests à écrire/vérifier** : `app:sync-permissions` OK ; cohérence personas (pas de drift). Lance `make test`.
|
||||
|
||||
**Scope STRICT** : RBAC + sidebar + 3 miroirs. Rien d'autre.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si test vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-153-rbac
|
||||
tea pr create --base develop --head feat/erp-153-rbac \
|
||||
--title "feat(transport) : permissions carriers + sidebar (ERP-153)" \
|
||||
--description "RBAC transport.carriers.* + 3 sources RBAC alignées. Ticket ERP-153."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,54 @@
|
||||
# WT3 ⭐ — Migration + entités Carrier* + ApiResource + Provider (tickets 1.3 + 1.5 / ERP-155 + ERP-157)
|
||||
|
||||
> **Worktree pivot : il livre le CONTRAT JSON qui débloque tout le front.**
|
||||
> **Mode STACK, sans worktree** (repo principal) — base = branche de WT2 :
|
||||
> ```bash
|
||||
> cd /home/matthieu/dev_malio/Starseed && git fetch origin
|
||||
> git checkout -b feat/erp-155-carrier-schema-entities origin/feat/erp-153-rbac
|
||||
> ```
|
||||
> **Base** : `feat/erp-153-rbac` (contient ERP-150 + WT1 + RBAC WT2). Quand #111 sera mergé dans develop, la PR de WT3 se recible automatiquement sur develop.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (modular monolith DDD, Symfony 8 / API Platform 4). Lis `CLAUDE.md`, `.claude/rules/backend.md`, `.claude/rules/architecture.md`. **Charge le skill `backend-entity-conventions`** (patterns entités/migrations complets).
|
||||
|
||||
**Mission** : créer le schéma BDD du répertoire transporteurs + les entités + le contrat de lecture (liste + détail). Tu poses le contrat JSON sur lequel le front s'appuiera — c'est le livrable critique.
|
||||
|
||||
**Spec** : `spec-back.md § 3.2 / 3.3 / 3.4 / 4.0 / 4.1 / 4.2`. **Miroir = le module Supplier** : `src/Module/Commercial/Domain/Entity/Supplier*.php`, `…/Infrastructure/ApiPlatform/State/Provider/SupplierProvider.php`, `…/Serializer/SupplierReadGroupContextBuilder.php`. Carrier vit dans `src/Module/Transport/`.
|
||||
|
||||
### Étape A — Migration (`migrations/`, namespace racine `DoctrineMigrations`)
|
||||
- **PAS de migration modulaire** : même si la spec dit « modulaire », toute migration va dans `migrations/` namespace racine (tri FQCN cassant sinon). Postérieure à la dernière présente — vérifie `ls migrations/` (à ce jour `Version20260615120000`).
|
||||
- Tables `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` + FK : `qualimat_carrier`, `uploaded_document`, `client`, `client_address`, `supplier`, `supplier_address`, `site`, `user`.
|
||||
- `certification_type` **nullable** (null en cas LIOT) + CHECK enum ; CHECK sur `container_type`, `direction`, `pricing_unit`, `price_state`, branches Prix client/fournisseur.
|
||||
- Index partiel `uq_carrier_name_active` : `LOWER(name)` WHERE non archivé ET non supprimé.
|
||||
- **`COMMENT ON COLUMN` sur TOUTES les colonnes** (FR, ≤200 car.) + helper standard pour les 4 colonnes Timestampable/Blamable. Bonus `COMMENT ON TABLE`.
|
||||
|
||||
### Étape B — Entités + repos
|
||||
- `Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice` : `#[Auditable]`, `implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait`. Repos `*RepositoryInterface` (Domain) + `Doctrine*Repository` (Infrastructure).
|
||||
- `ApiResource` Carrier (attribut sur l'entité, comme Supplier) : `GetCollection` + `Get` + `Post` + `Patch` avec `security` (§ 3.3). **PAS de Delete**.
|
||||
- Groupes : `carrier:read`, `carrier:item:read`, `qualimat:read`. **Embed au détail** (pas IRI) : `client:read`/`client_address:read`/`supplier:read`/`supplier_address:read`/`site:read` + `qualimatCarrier`. ⚠ les adresses de l'onglet Prix sont des `ClientAddress`/`SupplierAddress` distinctes.
|
||||
- `CarrierProvider` paginé (`ApiPlatform\Doctrine\Orm\Paginator`), liste **sans cloisonnement site** (§ 2.3), **anti-N+1** (fetch joins, § 2.11), exclut les archivés par défaut + `?includeArchived=true`.
|
||||
- Piège booléen : `#[SerializedName('isArchived')]` sur le getter.
|
||||
|
||||
### Gardes-fous qui CASSENT `make test` (à traiter dans CE worktree)
|
||||
- `ColumnsHaveSqlCommentTest` → COMMENT partout **+ ajouter les blocs `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` dans `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php`** (sinon `test-db-setup` drope les COMMENT).
|
||||
- `makefile test-db-setup` : l'index partiel `uq_carrier_name_active` n'est PAS exprimé par `schema:update` → **ajoute-le à la ligne `dbal:run-sql` du target `test-db-setup`** du `makefile`, sinon `make test` casse.
|
||||
- `AuditableEntitiesHaveI18nLabelTest` → ajoute dans `frontend/i18n/locales/fr.json` les clés `audit.entity.transport_carrier`, `transport_carrieraddress`, `transport_carriercontact`, `transport_carrierprice` (clé = strtolower(module)+'_'+strtolower(Entity)).
|
||||
- `EntitiesAreTimestampableBlamableTest`, `EntityConstraintsHaveFrenchMessageTest` (messages FR + `Length.max` = longueur colonne), `CollectionsArePaginatedTest`.
|
||||
|
||||
**Scope STRICT** : schéma + entités + ApiResource lecture + Provider + i18n audit. **PAS** le Processor d'écriture (→ WT4), **PAS** les sous-ressources POST/PATCH adresses/contacts/prix (→ WT6/7/8), **PAS** l'export (→ WT9). Mets un `CarrierFixtures` **minimal** (1-2 lignes) juste pour faire tourner tes tests de lecture ; les fixtures complètes sont faites par WT10 — n'y investis pas.
|
||||
|
||||
**Tests à écrire** : liste exclut archivés / `?includeArchived=true` ; enveloppe Hydra (`member`/`totalItems`) ; `isArchived` présent dans le JSON ; embeds détail présents (pas IRI).
|
||||
|
||||
**LIVRABLE GATE** : une fois vert, **capture le JSON réel liste + détail** (`curl` ou test) et colle-le dans `spec-back.md § 4.0.bis`. C'est le signal pour démarrer le front. Préviens la conv maître.
|
||||
|
||||
**Fini quand** : `make db-reset` OK + `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si test vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-155-carrier-schema-entities
|
||||
tea pr create --base feat/erp-153-rbac --head feat/erp-155-carrier-schema-entities \
|
||||
--title "feat(transport) : schéma + entités Carrier + contrat lecture (ERP-155/157)" \
|
||||
--description "Migration + entités Carrier* + ApiResource lecture + Provider + i18n audit + contrat JSON. Tickets ERP-155, ERP-157."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,41 @@
|
||||
# WT4 — CarrierProcessor (ticket 1.6 / ERP-158)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp158-processor -b feat/erp-158-carrier-processor origin/develop
|
||||
> cd ../sb-erp158-processor && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de WT3** (entités Carrier) **et WT1** (upload, pour la décharge).
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
|
||||
|
||||
**Mission** : logique d'écriture du formulaire principal Carrier (POST/PATCH) — normalisation, champs conditionnels, archivage. **Miroir** : `src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php` + `Application/Service/SupplierFieldNormalizer.php`.
|
||||
|
||||
**Spec** : `spec-back.md § 4.3 / 4.4 / 7`.
|
||||
|
||||
**Règles métier à implémenter (un test PHPUnit par RG)** :
|
||||
- **RG-4.01** : POST avec `qualimatCarrier` → `certificationType=QUALIMAT` + FK persistée ; cas LIOT (`name='LIOT'`) ⇒ `certificationType` non requis, `liotPlates` accepté.
|
||||
- **RG-4.02** : `certificationType='AUTRE'` sans `dischargeDocument` → **422** (`#[Assert\Callback]`).
|
||||
- **RG-4.03** : `isChartered=true` sans `indexationRate` / `containerType` / `volumeM3` → **422**.
|
||||
- **RG-4.13** : normalisation via `CarrierFieldNormalizer` (miroir Supplier) — `name` UPPER, contacts Capitalize, phones digits-only, email lower, `liotPlates` (`;`-split/trim/UPPER).
|
||||
- **RG-4.12** : doublon `name` (parmi actifs) → **409** + `setError` ciblé.
|
||||
- **RG-4.14** : PATCH `isArchived` exige `transport.carriers.archive` (Admin) ; mode strict → 403 sinon.
|
||||
|
||||
**Pièges** :
|
||||
- Messages de validation **FR explicites** sur chaque contrainte (`EntityConstraintsHaveFrenchMessageTest`).
|
||||
- Le back renvoie **toutes** les violations d'un coup avec `propertyPath` aligné sur les champs front.
|
||||
|
||||
**Scope STRICT** : `CarrierProcessor` + `CarrierFieldNormalizer` + contraintes sur l'entité `Carrier` (formulaire principal). **NE TOUCHE PAS** : les sous-ressources adresses/contacts/prix (WT6/7/8), `CarrierFixtures` (WT10), l'export (WT9). Ajoute tes contraintes sur `Carrier` sans réécrire l'ApiResource posée par WT3.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-158-carrier-processor
|
||||
tea pr create --base develop --head feat/erp-158-carrier-processor \
|
||||
--title "feat(transport) : CarrierProcessor (RG-4.01→4.03/4.12→4.14) (ERP-158)" \
|
||||
--description "Normalisation + champs conditionnels + archive. Ticket ERP-158."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,37 @@
|
||||
# WT5 — Endpoint QualimatCarrier lecture seule (ticket 1.4 / ERP-156)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp156-qualimat -b feat/erp-156-qualimat-search origin/develop
|
||||
> cd ../sb-erp156-qualimat && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de WT2** (permission `transport.carriers.view`) **et ERP-39** (table `qualimat_carrier` peuplée). **Indépendant de WT3** — peut tourner en parallèle.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
|
||||
|
||||
**Mission** : exposer le référentiel QUALIMAT (table existante `qualimat_carrier`, alimentée par console) en **lecture seule** + endpoint de recherche pour la saisie assistée du nom (RG-4.01). **Ne touche pas** la commande de sync.
|
||||
|
||||
**Spec** : `spec-back.md § 4.7` + RG-4.01.
|
||||
|
||||
**À livrer** :
|
||||
1. Entité `QualimatCarrier` (lecture seule) mappée sur la table existante `qualimat_carrier`. **Aucune écriture exposée** (pas de Post/Patch/Delete). Probablement pas `#[Auditable]` ni Timestampable (référentiel externe synchronisé) — vérifie le mapping existant.
|
||||
2. `GET /api/qualimat_carriers?search=` : fuzzy sur `name` (+ `siret`), **seulement `is_active = true`**, tri `name`, **paginé** (règle n°13 — `CollectionsArePaginatedTest`).
|
||||
3. **Security** `is_granted('transport.carriers.view')`.
|
||||
4. Champs exposés : `id, siret, name, address, postalCode, city, phone, department, status, validityDate, isActive`.
|
||||
|
||||
**Tests à écrire** : recherche ne renvoie que les actifs ; pagination Hydra ; 403 sans permission ; tri `name`.
|
||||
|
||||
**Scope STRICT** : uniquement l'exposition lecture de `qualimat_carrier`. Ne crée rien autour de `Carrier` (autres worktrees). Si la table n'a pas de COMMENT (référentiel pré-existant), vérifie si elle est dans `EXCLUDED_TABLES` de `ColumnsHaveSqlCommentTest` — ne casse pas ce test.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-156-qualimat-search
|
||||
tea pr create --base develop --head feat/erp-156-qualimat-search \
|
||||
--title "feat(transport) : endpoint recherche QualimatCarrier (ERP-156)" \
|
||||
--description "Entité lecture seule + GET /api/qualimat_carriers?search=. Ticket ERP-156."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,37 @@
|
||||
# WT6 — Sous-ressource Adresses (ticket 1.7 / ERP-159)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp159-adresses -b feat/erp-159-carrier-addresses origin/develop
|
||||
> cd ../sb-erp159-adresses && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de WT3** (entités `CarrierAddress`). Parallèle à WT5/WT7/WT8/WT9.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `SupplierAddressProcessor.php` (`src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/`).
|
||||
|
||||
**Mission** : opérations d'écriture sur les adresses transporteur.
|
||||
|
||||
**Spec** : `spec-back.md § 4.5` + RG-4.05→4.07.
|
||||
|
||||
**À livrer** :
|
||||
- `POST /api/carriers/{id}/addresses`, `PATCH`/`DELETE /api/carrier_addresses/{id}` (security `manage`) — **resource/processor dédiés à `CarrierAddress`**, ne modifie pas l'ApiResource `Carrier`.
|
||||
- **RG-4.06** : `postalCode` matche `^[0-9]{4,5}$` (autocomplete ville = front). Message FR.
|
||||
- **RG-4.05** : si affrété → adresse obligatoire (Pays/CP/Ville/Adresse) — validation conditionnelle.
|
||||
- RG-4.07 (bouton Valider masqué si QUALIMAT) = front ; côté back, accepter le PATCH normalement.
|
||||
|
||||
**Tests à écrire** : CP invalide → 422 ; adresse affrété incomplète → 422 ; PATCH/DELETE OK avec `manage`, 403 sans.
|
||||
|
||||
**Scope STRICT** : uniquement `CarrierAddress` (resource + processor + tests). **NE TOUCHE PAS** `CarrierFixtures` (WT10), l'entité `Carrier`, les autres sous-ressources. Messages de validation FR (`EntityConstraintsHaveFrenchMessageTest`).
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-159-carrier-addresses
|
||||
tea pr create --base develop --head feat/erp-159-carrier-addresses \
|
||||
--title "feat(transport) : sous-ressource adresses transporteur (ERP-159)" \
|
||||
--description "POST/PATCH/DELETE carrier_address + RG-4.05→4.07. Ticket ERP-159."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,35 @@
|
||||
# WT7 — Sous-ressource Contacts (ticket 1.8 / ERP-160)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp160-contacts -b feat/erp-160-carrier-contacts origin/develop
|
||||
> cd ../sb-erp160-contacts && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de WT3**. Parallèle à WT5/WT6/WT8/WT9.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `SupplierContactProcessor.php` (`src/Module/Commercial/…/State/Processor/`).
|
||||
|
||||
**Mission** : opérations d'écriture sur les contacts transporteur.
|
||||
|
||||
**Spec** : `spec-back.md § 4.5` + RG-4.08.
|
||||
|
||||
**À livrer** :
|
||||
- `POST /api/carriers/{id}/contacts`, `PATCH`/`DELETE /api/carrier_contacts/{id}` (security `manage`) — resource/processor dédiés à `CarrierContact`.
|
||||
- **RG-4.08** : bloc valide si **≥ 1 champ rempli** (CHECK `chk_carrier_contact_filled` côté migration WT3 + validation Processor) ; **max 2 téléphones**.
|
||||
|
||||
**Tests à écrire** : contact vide → 422 ; 1 champ → 200/201 ; 3ᵉ téléphone → 422.
|
||||
|
||||
**Scope STRICT** : uniquement `CarrierContact`. **NE TOUCHE PAS** `CarrierFixtures` (WT10), `Carrier`, les autres sous-ressources. Messages FR. Si le CHECK `chk_carrier_contact_filled` manque (WT3 ne l'a pas posé), valide côté Processor et signale-le à la conv maître.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-160-carrier-contacts
|
||||
tea pr create --base develop --head feat/erp-160-carrier-contacts \
|
||||
--title "feat(transport) : sous-ressource contacts transporteur (ERP-160)" \
|
||||
--description "POST/PATCH/DELETE carrier_contact + RG-4.08 (≥1 champ, max 2 tel). Ticket ERP-160."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,39 @@
|
||||
# WT8 — Sous-ressource Prix + RG branches (ticket 1.9 / ERP-161)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp161-prix -b feat/erp-161-carrier-prices origin/develop
|
||||
> cd ../sb-erp161-prix && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de WT3**. Parallèle à WT5/WT6/WT7/WT9.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
|
||||
|
||||
**Mission** : opérations d'écriture sur les prix transporteur, avec branches Client / Fournisseur.
|
||||
|
||||
**Spec** : `spec-back.md § 4.5 / 7` + RG-4.09→4.11.
|
||||
|
||||
**À livrer** :
|
||||
- `POST /api/carriers/{id}/prices`, `PATCH`/`DELETE /api/carrier_prices/{id}` (security `manage`) — resource/processor dédiés à `CarrierPrice`.
|
||||
- **RG-4.10 (CLIENT)** : `client`, `clientDeliveryAddress`, `departureSite` requis ; `clientDeliveryAddress` **doit appartenir au `client`** → sinon 422.
|
||||
- **RG-4.11 (FOURNISSEUR)** : `supplier`, `supplierSupplyAddress`, `deliverySite` requis ; `supplierSupplyAddress` appartient au `supplier` → sinon 422.
|
||||
- Communs obligatoires : `containerType`, `pricingUnit`, `price`, `priceState`. CHECK branches respectés.
|
||||
|
||||
**Rappels FK** : « Adresse départ/livraison 86/17/82 » = `Site` (FK). Livraison client = `ClientAddress`, appro = `SupplierAddress` (relations ORM partagées — pas de M2M).
|
||||
|
||||
**Tests à écrire** : branche CLIENT/FOURNISSEUR incomplète → 422 ; adresse étrangère au client/supplier → 422 ; prix valide → 201.
|
||||
|
||||
**Scope STRICT** : uniquement `CarrierPrice`. **NE TOUCHE PAS** `CarrierFixtures` (WT10), `Carrier`, les autres sous-ressources. Messages FR.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-161-carrier-prices
|
||||
tea pr create --base develop --head feat/erp-161-carrier-prices \
|
||||
--title "feat(transport) : sous-ressource prix transporteur (ERP-161)" \
|
||||
--description "POST/PATCH/DELETE carrier_price + RG-4.09→4.11 (branches client/fournisseur). Ticket ERP-161."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,36 @@
|
||||
# WT9 — Export XLSX (ticket 1.10 / ERP-162)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp162-export -b feat/erp-162-carrier-export origin/develop
|
||||
> cd ../sb-erp162-export && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de WT3** (lecture Carrier). Parallèle à WT5/WT6/WT7/WT8.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. **Miroir** : `src/Module/Commercial/Infrastructure/Controller/SupplierExportController.php` (PhpSpreadsheet déjà présent).
|
||||
|
||||
**Mission** : export Excel du répertoire et du tableau Prix regroupé.
|
||||
|
||||
**Spec** : `spec-back.md § 4.6`.
|
||||
|
||||
**À livrer** :
|
||||
- `GET /api/carriers/export.xlsx` : transporteurs affichés (**mêmes filtres** que la liste) ; colonnes § 4.6.
|
||||
- `GET /api/carriers/{id}/prices/export.xlsx` : tableau Prix regroupé Benne / Fond Mouvant (colonnes docx p.10).
|
||||
- **Controllers custom** avec `#[Route(priority: 1)]` (sinon conflit API Platform `{id}`) ; en-tête `Content-Disposition`.
|
||||
|
||||
**Tests à écrire** : 200 + en-tête fichier (Content-Disposition + type XLSX) ; respect des filtres.
|
||||
|
||||
**Scope STRICT** : controllers d'export + service de génération. **NE TOUCHE PAS** entités, processors, `CarrierFixtures` (WT10). Réutilise le Provider/filtres de WT3 pour la cohérence des données exportées.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-162-carrier-export
|
||||
tea pr create --base develop --head feat/erp-162-carrier-export \
|
||||
--title "feat(transport) : export XLSX répertoire + prix (ERP-162)" \
|
||||
--description "GET /api/carriers/export.xlsx + /carriers/{id}/prices/export.xlsx. Ticket ERP-162."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,994 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M4
|
||||
nom: "Répertoire transporteurs"
|
||||
ecran: repertoire-transporteurs
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0.1
|
||||
date_redaction: 2026-06-15
|
||||
# Historique :
|
||||
# V0.1 (2026-06-15) — Spec back initiale. S'appuie sur le module `Transport` déjà créé
|
||||
# (ERP-150) et sur les référentiels synchronisés `qualimat_carrier` (ERP-39) et
|
||||
# `idtf_product` (ERP-149). Restitution + précisions back du docx fonctionnel
|
||||
# « M4-repertoire-transporteurs-V0 » (validé 27/05/2026) et de la maquette Figma.
|
||||
# Décisions Matthieu (15/06) : lien QUALIMAT = FK + copie éditable ; PAS de cloisonnement
|
||||
# par site ; infra d'upload réutilisable dans `Shared` (plusieurs usages à venir).
|
||||
|
||||
# === LIENS ===
|
||||
spec_front: ./spec-front.md
|
||||
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-45376&p=f&m=dev"
|
||||
trace_fonctionnelle: "uploads/M4-repertoire-transporteurs-V0.pdf / .docx (V0, validé 27/05/2026)"
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_project_id: 6
|
||||
lesstime_taskgroup_id: 31 # M4 — Répertoire transporteurs (tickets ERP-153 → ERP-171)
|
||||
statut_global: pret_a_dev
|
||||
|
||||
# === DÉPENDANCES AMONT ===
|
||||
depend_de:
|
||||
- Transport # module créé (ERP-150) ; référentiels qualimat_carrier (ERP-39) + idtf_product (ERP-149)
|
||||
- Commercial # Client (M1) + Supplier (M2) + leurs adresses → onglet Prix
|
||||
- Sites # SitesModule + 3 sites (86 / 17 / 82) — adresses départ/livraison du Prix
|
||||
- Core # User, Role, Permission, Audit, JWT déjà en place
|
||||
- Shared # TimestampableBlamableTrait + Subscriber (ERP-52) + NOUVELLE infra upload (§ 2.7)
|
||||
---
|
||||
|
||||
# Spec back — Module 4 : Répertoire transporteurs
|
||||
|
||||
## 1. Contexte
|
||||
|
||||
Cette spec **complète et précise** la [spec front V0.1](./spec-front.md) (docx `M4-repertoire-transporteurs-V0`, validé le 27/05/2026) avec tout ce qui touche au back : décisions d'archi, modèle de données, migration, API REST, RBAC, règles de gestion (RG-4.01 → RG-4.11 + précisions back), tests, hors-périmètre.
|
||||
|
||||
**Module cible** : module **`Transport`** **déjà créé** (`src/Module/Transport/`, ERP-150). Le M4 lui **ajoute son premier périmètre fonctionnel exposé** : le **répertoire des transporteurs** (entité `Carrier` éditée par l'utilisateur), qui s'appuie sur les **référentiels déjà synchronisés par commandes console** :
|
||||
|
||||
- **`qualimat_carrier`** (ERP-39) — transporteurs agréés QUALIMAT, synchro quotidienne depuis qualimat.org. Sert la **saisie assistée** du nom (RG-4.01).
|
||||
- **`idtf_product`** (ERP-149) — codes IDTF (régimes de nettoyage). **Pas utilisé par les écrans M4** (référentiel autonome, hors périmètre des écrans transporteurs — cf. § 9).
|
||||
|
||||
> **À ce stade `TransportModule::permissions()` renvoie `[]`** (cf. branche `feat/erp-150-module-transport`). Le M4 le remplit (§ 5.1) et expose la première section sidebar du module.
|
||||
|
||||
> **RETEX obligatoire** : le M4 réutilise le pattern de sérialisation éprouvé M1/M2/M3 (`spec-back.md` des modules clients/fournisseurs/prestataires). ~80 % des frictions venaient du **contrat de sérialisation** (groupes / sous-ressources / embed), pas du métier. La section § 4.0 applique ce RETEX au M4.
|
||||
|
||||
**Dépendances déjà en place sur `develop`** :
|
||||
- `Transport` → tables `qualimat_carrier` / `qualimat_sync_log` / `idtf_product` / `idtf_sync_log` (migrations `Version20260612150000` / `Version20260612160000`).
|
||||
- `Commercial` → `Client` (M1) + `Supplier` (M2) + leurs adresses (onglet Prix).
|
||||
- `Sites` → 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82).
|
||||
- `Shared` → `TimestampableBlamableTrait` + `Subscriber` (ERP-52).
|
||||
- `Core` → User, Role, Permission, Audit, JWT.
|
||||
|
||||
## 2. Décisions d'archi
|
||||
|
||||
### 2.1 Entité `Carrier` dans le module `Transport` (pas de nouveau module)
|
||||
|
||||
Le répertoire transporteurs vit dans le **module `Transport` existant**. On crée l'entité **`Carrier`** (transporteur saisi par l'utilisateur) + ses sous-collections `CarrierAddress`, `CarrierContact`, `CarrierPrice`, sous `src/Module/Transport/Domain/Entity/`.
|
||||
|
||||
**`Carrier` ≠ `qualimat_carrier`** :
|
||||
- `qualimat_carrier` est un **référentiel en lecture seule** alimenté par la synchro console (jamais édité par l'utilisateur).
|
||||
- `Carrier` est l'**entité métier éditable** du répertoire. Elle **peut** référencer une ligne `qualimat_carrier` (lien QUALIMAT — § 2.5) mais existe aussi pour des transporteurs non-QUALIMAT (GMP+, OVOCOM, compte-propre, LIOT, autre).
|
||||
|
||||
**Référentiels cross-module consommés en relation ORM partagée (PAS d'import de logique)** — exactement comme M2/M3 : l'onglet Prix référence `Client` / `Supplier` (module Commercial), leurs adresses, et `Site` (module Sites) via des **relations ORM** (ManyToOne). Ce sont des **données de référence partagées**, pas de la logique inter-module (aucun service/repository d'un autre module appelé). Conforme à la tolérance déjà actée M1/M2/M3 (règle ABSOLUE n°1 vise les dépendances de **logique** métier).
|
||||
|
||||
### 2.2 IDs — cohérence avec le référentiel Transport
|
||||
|
||||
Les tables référentielles du module Transport utilisent `BIGINT GENERATED BY DEFAULT AS IDENTITY` (cf. `qualimat_carrier`). Les **nouvelles** tables métier M4 (`carrier` et sous-collections) suivent la même convention **`BIGINT GENERATED BY DEFAULT AS IDENTITY`** pour rester homogène **dans le module Transport** (différence assumée vs `INT` des modules M1/M2/M3 — on s'aligne sur le module hôte). Horodatages en `TIMESTAMP(0) WITHOUT TIME ZONE` (le `TimestampableBlamableTrait` mappe `datetime_immutable`).
|
||||
|
||||
> **Point de raffinement (non bloquant)** : si l'on préfère l'homogénéité globale Starseed (`INT`), basculer toutes les tables M4 en `INT`. Décision par défaut retenue ici : `BIGINT` (cohérence intra-module Transport). À confirmer au ticket migration.
|
||||
|
||||
### 2.3 Pas de cloisonnement par site (DÉCISION Matthieu, 15/06/2026)
|
||||
|
||||
> **Décision** : le répertoire transporteurs est un **référentiel global** — **aucun cloisonnement par site** (contrairement au M3 prestataires). Tout rôle autorisé en consultation (Admin / Bureau / Commerciale) voit **tous** les transporteurs. Conforme à la colonne « Consultation = Tout » du docx pour ces rôles.
|
||||
|
||||
Conséquence : **pas** de `ProviderSiteScopeExtension`, pas de `currentSite` dans le filtrage, pas de `sites.bypass_scope`. Le `Carrier` **ne porte pas** de relation `sites` au niveau de la fiche (les sites n'apparaissent que dans l'onglet Prix comme **adresse de départ/livraison**, en valeur, pas comme périmètre de visibilité).
|
||||
|
||||
### 2.4 Archive vs soft delete — deux mécanismes distincts (identique M1/M2/M3)
|
||||
|
||||
| Mécanisme | Colonne | Visibilité défaut | Restauration | Utilisateur |
|
||||
|---|---|---|---|---|
|
||||
| **Archive** (fonctionnel) | `is_archived` (bool, default false) + `archived_at` | masqué | Oui (toggle UI) | **Admin seul** via `transport.carriers.archive` |
|
||||
| **Soft delete** (technique) | `deleted_at` (timestamptz nullable) | masqué | HP | Aucun rôle au M4 (HP) |
|
||||
|
||||
Conséquences (miroir M3) :
|
||||
- `DELETE /api/carriers/{id}` **non exposé** au M4 (404 si appelé).
|
||||
- `GET /api/carriers?includeArchived=true` permet de voir les archivés (permission `transport.carriers.view`).
|
||||
- PATCH `{ "isArchived": true }` archive ; PATCH `{ "isArchived": false }` restaure.
|
||||
- L'unicité métier ignore les archivés ET les soft-deletés (§ 2.6).
|
||||
|
||||
### 2.5 Lien QUALIMAT — FK + copie éditable (DÉCISION Matthieu, 15/06/2026)
|
||||
|
||||
> **Décision** : quand l'utilisateur sélectionne un transporteur dans l'onglet QUALIMAT (RG-4.01), on **conserve une FK** `carrier.qualimat_carrier_id` **ET** on **copie** au moment de la sélection : `name`, la certification (`certification_type = QUALIMAT`) et les champs adresse (pays / code postal / ville / voie) dans une `CarrierAddress`. Les champs copiés **restent éditables** et **survivent à une désync QUALIMAT** (FK `ON DELETE SET NULL`).
|
||||
|
||||
- `qualimat_carrier_id` : FK nullable vers `qualimat_carrier(id)`, `ON DELETE SET NULL` (si la ligne QUALIMAT disparaît du référentiel, le transporteur du répertoire est conservé, lien rompu proprement).
|
||||
- **Pas de FK figée à la migration** vers le référentiel pour les autres champs : on copie les **valeurs** (snapshot éditable). Le lien sert à la traçabilité de la source + au statut/date de validité QUALIMAT affichés (`qualimat_carrier.status` / `validity_date`, RG-4.04).
|
||||
- **Certification d'un transporteur QUALIMAT** : `certification_type = 'QUALIMAT'`, **lecture seule** côté front tant que `qualimat_carrier_id` est non nul. Les transporteurs non-QUALIMAT prennent une valeur de la liste `GMP_PLUS` / `OVOCOM` / `COMPTE_PROPRE` / `AUTRE` (RG-4.02).
|
||||
- **Modal de confirmation** « Êtes-vous sûr de vouloir intégrer ce transporteur ? » : pur front (RG-4.01 / RG-4.03 du docx) — au back c'est un simple POST/PATCH portant `qualimatCarrier` + les valeurs copiées.
|
||||
|
||||
### 2.6 Unicité partielle Postgres — nom de transporteur
|
||||
|
||||
> **Décision (alignée M1/M2/M3 § 2.6)** : l'unicité métier porte **uniquement sur le nom** (`carrier.name`). Pas d'unicité sur le SIRET (le référentiel QUALIMAT lui-même a des SIRET parfois incomplets) ni ailleurs.
|
||||
|
||||
Index unique partiel (`WHERE is_archived = FALSE AND deleted_at IS NULL`) sur `LOWER(name)`. Doublon → `409 Conflict` géré par le `CarrierProcessor`.
|
||||
|
||||
> **Cas LIOT (RG-4.01)** : « LIOT » est un transporteur compte-propre particulier (flotte interne). Le nom `LIOT` reste soumis à l'unicité comme les autres (un seul `Carrier` nommé LIOT actif). Voir § 2.9 pour le comportement de saisie.
|
||||
|
||||
### 2.7 Upload de fichiers — infra réutilisable dans `Shared` (DÉCISION Matthieu, 15/06/2026)
|
||||
|
||||
Le champ **« Décharge »** (upload, visible si `certification_type = AUTRE` — RG-4.02) est le **premier** d'une **série d'uploads à venir** dans l'ERP (« il va y en avoir pas mal »). On **ne fait donc pas** un upload ad hoc sur `carrier` : on pose une **infra d'upload générique et réutilisable** dans `Shared`.
|
||||
|
||||
**Proposition (à câbler au ticket dédié)** :
|
||||
- Table `uploaded_document` (module `Shared` / `Core`) : `id`, `original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum` (sha256), `created_at`, `created_by`.
|
||||
- Service `Shared\Infrastructure\Upload\FileUploader` : valide le MIME **côté serveur via `$file->getMimeType()`** (jamais `getClientMimeType()` — règle ABSOLUE backend), borne la taille, calcule le checksum, écrit sur disque (chemin configurable `%kernel.project_dir%/var/uploads/{yyyy}/{mm}/`), persiste la ligne, retourne l'IRI `/api/uploaded_documents/{id}`.
|
||||
- Endpoint `POST /api/uploaded_documents` (multipart, `#[ApiResource]` + Processor dédié) → renvoie l'IRI ; whitelist MIME (PDF + images au minimum pour la décharge).
|
||||
- `carrier.discharge_document_id` : FK nullable vers `uploaded_document(id)`, `ON DELETE SET NULL`.
|
||||
|
||||
> **Périmètre M4** : livrer l'infra upload **minimale mais générique** (table + service + endpoint + 1 consommateur = la décharge). Les autres consommateurs (pièces jointes contrats, documents fournisseurs, etc.) la **réutiliseront** sans la réécrire. La conception détaillée de l'infra (antivirus, stockage objet S3, purge) est tracée HP-M4-… (§ 9).
|
||||
> **Garde-fou MIME** : valider serveur (`$file->getMimeType()`), whitelist explicite, refuser le reste → 422.
|
||||
|
||||
### 2.8 Audit & traces temporelles
|
||||
|
||||
Pattern Starseed standard, miroir M1/M2/M3 :
|
||||
- `#[Auditable]` sur `Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice`.
|
||||
- **Tous les champs auditables** (pas de champ sensible type password/token ici → pas d'`#[AuditIgnore]`).
|
||||
- Audit des FK (`qualimatCarrier`, `client`, `supplier`, `departureSite`…) tracé automatiquement.
|
||||
- **Libellés i18n** (règle ABSOLUE backend — `AuditableEntitiesHaveI18nLabelTest`) : ajouter dans `frontend/i18n/locales/fr.json` (clé = `strtolower(module)` + `_` + `strtolower(Entity)`) :
|
||||
`audit.entity.transport_carrier`, `audit.entity.transport_carrieraddress`, `audit.entity.transport_carriercontact`, `audit.entity.transport_carrierprice`.
|
||||
|
||||
### 2.9 Workflow de saisie & champs conditionnels (formulaire principal)
|
||||
|
||||
Le **formulaire principal** porte des champs **conditionnels** (RG-4.02 / RG-4.03 / cas LIOT). Le back **ne maintient pas de state machine** : il stocke ce qui est envoyé et **valide la cohérence** au POST/PATCH. Logique :
|
||||
|
||||
| Déclencheur | Champs activés / obligatoires | RG |
|
||||
|---|---|---|
|
||||
| `qualimat_carrier_id` non nul (transporteur QUALIMAT) | `certification_type = QUALIMAT` (lecture seule) ; `name` + adresse copiés | RG-4.01 |
|
||||
| `name == 'LIOT'` (cas spécial) | `liot_plates` visible et seul champ pertinent ; les autres champs (certif/affrété/benne/volume) masqués | RG-4.01 |
|
||||
| `certification_type == AUTRE` | `discharge_document` (upload Décharge) visible | RG-4.02 |
|
||||
| `is_chartered == true` (« Affréter » coché) | `indexation_rate`, `container_type` (Benne/Fond mouvant), `volume_m3` visibles **et obligatoires** | RG-4.03 |
|
||||
|
||||
> **Validation incrémentale par onglet (workflow front-driven, identique M2/M3)** : `Carrier` créé en BDD **dès validation du formulaire principal** via `POST /api/carriers`. Onglets suivants (Adresse / Contact / Prix) → **PATCH partiels** / **sous-ressources** avec groupes de sérialisation dédiés :
|
||||
> - `carrier:write:main` — formulaire principal (POST + PATCH)
|
||||
> - `carrier:write:addresses` — onglet Adresse (sous-ressource `carrier_address`)
|
||||
> - `carrier:write:contacts` — onglet Contact (sous-ressource `carrier_contact`)
|
||||
> - `carrier:write:prices` — onglet Prix (sous-ressource `carrier_price`)
|
||||
> - `carrier:write:archive` — toggle archive (security `transport.carriers.archive`)
|
||||
|
||||
### 2.10 Normalisation serveur des entrées texte (identique M1/M2/M3)
|
||||
|
||||
`CarrierFieldNormalizer` (miroir `SupplierFieldNormalizer`/`ProviderFieldNormalizer`), service interne appelé par les Processors avant validation :
|
||||
|
||||
```php
|
||||
final class CarrierFieldNormalizer
|
||||
{
|
||||
public function normalizeName(?string $v): ?string // mb_strtoupper(trim) → RG-4.12
|
||||
public function normalizePersonName(?string $v): ?string // mb_convert_case TITLE
|
||||
public function normalizeEmail(?string $v): ?string // mb_strtolower(trim)
|
||||
public function normalizePhone(?string $v): ?string // preg_replace('/\D+/', '')
|
||||
public function normalizeLiotPlates(?string $v): ?string // split ';', trim, UPPER, rejoin '; '
|
||||
}
|
||||
```
|
||||
|
||||
Le formatage `XX XX XX XX XX` (téléphones) est fait à l'affichage front. Le back stocke `0612345678` (chiffres seuls).
|
||||
|
||||
### 2.11 Liste : embed + hydratation anti-N+1 (cohérence M1/M2/M3)
|
||||
|
||||
La **liste** `GET /api/carriers` **embarque** le minimum nécessaire au datatable (cf. § 4.0) : `name`, `certificationType`, statut/date de validité QUALIMAT (depuis `qualimatCarrier` embarqué, RG-4.04), `updatedAt`. Anti-N+1 : le `DoctrineCarrierRepository` ne fetch-joine PAS les to-many (contacts/adresses/prix) dans la requête de liste ; il fetch-joine au plus `qualimat_carrier` (ManyToOne, sûr). Le contrat de sérialisation (groupes dans le contexte) est posé **une seule fois** sur l'entité.
|
||||
|
||||
## 3. Modèle de données
|
||||
|
||||
### 3.1 Diagramme
|
||||
|
||||
```
|
||||
+------------------------+ +------------------------+
|
||||
| qualimat_carrier |<--n:1--| carrier |
|
||||
| (référentiel ERP-39, | (FK | id (PK) |
|
||||
| lecture seule) | nullable| name (UNIQUE actif) |
|
||||
+------------------------+ SET NULL)| certification_type |
|
||||
| is_chartered |
|
||||
+------------------------+ | indexation_rate |
|
||||
| uploaded_document |<--n:1-- discharge_document_id ---| container_type |
|
||||
| (Shared, § 2.7) | (FK nullable) | volume_m3 |
|
||||
+------------------------+ | liot_plates |
|
||||
| is_archived / deleted |
|
||||
carrier 1:n carrier_address +---------------------+ +------------------------+
|
||||
carrier 1:n carrier_contact | carrier_price | | 1:n
|
||||
carrier 1:n carrier_price ------>| direction CLIENT/ | |
|
||||
| FOURNISSEUR | +------------------+
|
||||
(Prix → relations ORM partagées) | client_id (M1) |-->| client (M1) |
|
||||
| client_delivery_addr| | supplier (M2) |
|
||||
| departure_site_id |-->| site (Sites) |
|
||||
| supplier_id (M2) | | client_address |
|
||||
| supplier_supply_addr| | supplier_address |
|
||||
| delivery_site_id | +------------------+
|
||||
| container_type |
|
||||
| pricing_unit |
|
||||
| price / price_state |
|
||||
+---------------------+
|
||||
```
|
||||
|
||||
### 3.2 Migration Doctrine — SQL Postgres
|
||||
|
||||
Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/VersionYYYYMMDDHHMMSS.php` (à dater, **postérieur** à `Version20260612160000`).
|
||||
|
||||
> **Même justification qu'aux M1/M2/M3** : la migration crée un schéma avec **FK cross-module** (`user`, `client`, `supplier`, `site`, `qualimat_carrier`, `uploaded_document`). Le namespace modulaire casserait l'ordre (`make db-reset`) — exception racine de la règle ABSOLUE n°11.
|
||||
|
||||
> **Rappel règle ABSOLUE n°12** : chaque colonne créée DOIT recevoir son `COMMENT ON COLUMN` (FR, ≤ 200 car., sémantique + contrainte/RG). Les 4 colonnes Timestampable/Blamable passent par le helper `addStandardTimestampableBlamableComments`. SQL ci-dessous *illustratif* (style aligné module Transport : `BIGINT GENERATED BY DEFAULT AS IDENTITY`, `TIMESTAMP(0)`).
|
||||
|
||||
```sql
|
||||
-- =====================================================================
|
||||
-- Infra upload générique (Shared) — § 2.7
|
||||
-- =====================================================================
|
||||
CREATE TABLE uploaded_document (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
original_filename VARCHAR(255) NOT NULL,
|
||||
stored_path VARCHAR(512) NOT NULL,
|
||||
mime_type VARCHAR(128) NOT NULL,
|
||||
size_bytes INT NOT NULL,
|
||||
checksum VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT REFERENCES "user"(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- Table principale `carrier` (transporteur du répertoire)
|
||||
-- =====================================================================
|
||||
CREATE TABLE carrier (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
-- Lien référentiel QUALIMAT (FK + copie éditable — § 2.5)
|
||||
qualimat_carrier_id BIGINT REFERENCES qualimat_carrier(id) ON DELETE SET NULL,
|
||||
-- Formulaire principal
|
||||
name VARCHAR(255) NOT NULL,
|
||||
certification_type VARCHAR(20), -- QUALIMAT|GMP_PLUS|OVOCOM|COMPTE_PROPRE|AUTRE ; null seulement en cas LIOT (RG-4.01). Requis sinon (Processor).
|
||||
is_chartered BOOLEAN NOT NULL DEFAULT FALSE, -- « Affréter » (RG-4.03)
|
||||
indexation_rate NUMERIC(5,2), -- % (si affrété — RG-4.03)
|
||||
container_type VARCHAR(12), -- BENNE|FOND_MOUVANT (si affrété — RG-4.03)
|
||||
volume_m3 NUMERIC(10,2), -- (si affrété — RG-4.03)
|
||||
discharge_document_id BIGINT REFERENCES uploaded_document(id) ON DELETE SET NULL, -- (si AUTRE — RG-4.02)
|
||||
liot_plates TEXT, -- immatriculations LIOT « ; » (cas LIOT — RG-4.01)
|
||||
-- Archive (exposé M4)
|
||||
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
archived_at TIMESTAMP(0) WITHOUT TIME ZONE,
|
||||
-- Soft delete (préparé, non exposé au M4)
|
||||
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE,
|
||||
-- Timestampable + Blamable
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
CONSTRAINT chk_carrier_certification_type
|
||||
CHECK (certification_type IS NULL OR certification_type IN ('QUALIMAT','GMP_PLUS','OVOCOM','COMPTE_PROPRE','AUTRE')),
|
||||
CONSTRAINT chk_carrier_container_type
|
||||
CHECK (container_type IS NULL OR container_type IN ('BENNE','FOND_MOUVANT'))
|
||||
);
|
||||
CREATE INDEX idx_carrier_is_archived ON carrier(is_archived);
|
||||
CREATE INDEX idx_carrier_deleted_at ON carrier(deleted_at);
|
||||
CREATE INDEX idx_carrier_qualimat ON carrier(qualimat_carrier_id);
|
||||
CREATE INDEX idx_carrier_created_by ON carrier(created_by);
|
||||
CREATE INDEX idx_carrier_updated_by ON carrier(updated_by);
|
||||
-- Unicité métier (partielle : ignore archives + soft-delete) — nom seul (§ 2.6)
|
||||
CREATE UNIQUE INDEX uq_carrier_name_active
|
||||
ON carrier (LOWER(name)) WHERE is_archived = FALSE AND deleted_at IS NULL;
|
||||
|
||||
-- =====================================================================
|
||||
-- Sous-collection : Adresses (1:n)
|
||||
-- =====================================================================
|
||||
CREATE TABLE carrier_address (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
carrier_id BIGINT NOT NULL REFERENCES carrier(id) ON DELETE CASCADE,
|
||||
country VARCHAR(80) NOT NULL DEFAULT 'France',
|
||||
postal_code VARCHAR(20),
|
||||
city VARCHAR(120),
|
||||
street VARCHAR(255),
|
||||
street_complement VARCHAR(255),
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX idx_carrier_address_carrier ON carrier_address(carrier_id);
|
||||
|
||||
-- =====================================================================
|
||||
-- Sous-collection : Contacts (1:n)
|
||||
-- =====================================================================
|
||||
CREATE TABLE carrier_contact (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
carrier_id BIGINT NOT NULL REFERENCES carrier(id) ON DELETE CASCADE,
|
||||
first_name VARCHAR(120),
|
||||
last_name VARCHAR(120),
|
||||
job_title VARCHAR(120),
|
||||
phone_primary VARCHAR(20),
|
||||
phone_secondary VARCHAR(20),
|
||||
email VARCHAR(180),
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
-- RG-4.08 : au moins 1 champ rempli (garanti côté Processor ; CHECK = garde-fou minimal)
|
||||
CONSTRAINT chk_carrier_contact_filled
|
||||
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)
|
||||
);
|
||||
CREATE INDEX idx_carrier_contact_carrier ON carrier_contact(carrier_id);
|
||||
|
||||
-- =====================================================================
|
||||
-- Sous-collection : Prix (1:n) — onglet Prix (RG-4.09 → RG-4.11)
|
||||
-- =====================================================================
|
||||
CREATE TABLE carrier_price (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
carrier_id BIGINT NOT NULL REFERENCES carrier(id) ON DELETE CASCADE,
|
||||
direction VARCHAR(12) NOT NULL, -- CLIENT|FOURNISSEUR (RG-4.09)
|
||||
-- Branche CLIENT (RG-4.10)
|
||||
client_id INT REFERENCES client(id) ON DELETE RESTRICT,
|
||||
client_delivery_address_id INT REFERENCES client_address(id) ON DELETE RESTRICT,
|
||||
departure_site_id INT REFERENCES site(id) ON DELETE RESTRICT, -- adresse de départ (86/17/82)
|
||||
-- Branche FOURNISSEUR (RG-4.11)
|
||||
supplier_id INT REFERENCES supplier(id) ON DELETE RESTRICT,
|
||||
supplier_supply_address_id INT REFERENCES supplier_address(id) ON DELETE RESTRICT, -- adresse d'approvisionnement
|
||||
delivery_site_id INT REFERENCES site(id) ON DELETE RESTRICT, -- adresse de livraison (86/17/82)
|
||||
-- Commun
|
||||
container_type VARCHAR(12) NOT NULL, -- BENNE|FOND_MOUVANT
|
||||
pricing_unit VARCHAR(8) NOT NULL, -- FORFAIT|TONNE
|
||||
price NUMERIC(12,2) NOT NULL,
|
||||
price_state VARCHAR(12) NOT NULL, -- EN_COURS|VALIDE|NON_VALIDE
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
CONSTRAINT chk_carrier_price_direction CHECK (direction IN ('CLIENT','FOURNISSEUR')),
|
||||
CONSTRAINT chk_carrier_price_container CHECK (container_type IN ('BENNE','FOND_MOUVANT')),
|
||||
CONSTRAINT chk_carrier_price_unit CHECK (pricing_unit IN ('FORFAIT','TONNE')),
|
||||
CONSTRAINT chk_carrier_price_state CHECK (price_state IN ('EN_COURS','VALIDE','NON_VALIDE')),
|
||||
-- RG-4.10 : si CLIENT, les colonnes client_* sont requises et les supplier_* nulles
|
||||
CONSTRAINT chk_carrier_price_client_branch CHECK (
|
||||
direction <> 'CLIENT' OR (client_id IS NOT NULL AND supplier_id IS NULL)
|
||||
),
|
||||
-- RG-4.11 : si FOURNISSEUR, les colonnes supplier_* sont requises et les client_* nulles
|
||||
CONSTRAINT chk_carrier_price_supplier_branch CHECK (
|
||||
direction <> 'FOURNISSEUR' OR (supplier_id IS NOT NULL AND client_id IS NULL)
|
||||
)
|
||||
);
|
||||
CREATE INDEX idx_carrier_price_carrier ON carrier_price(carrier_id);
|
||||
CREATE INDEX idx_carrier_price_client ON carrier_price(client_id);
|
||||
CREATE INDEX idx_carrier_price_supplier ON carrier_price(supplier_id);
|
||||
```
|
||||
|
||||
### 3.2.bis Commentaires SQL obligatoires (échantillon)
|
||||
|
||||
```php
|
||||
$this->addSql("COMMENT ON TABLE carrier IS 'Répertoire transporteurs (M4 Transport) — entités éditables, archivables. Distinct du référentiel qualimat_carrier.'");
|
||||
$this->addSql("COMMENT ON COLUMN carrier.name IS 'Raison sociale du transporteur — stockée en MAJUSCULES. Unique parmi non-archivés/non-supprimés (RG-4.12 / § 2.6).'");
|
||||
$this->addSql("COMMENT ON COLUMN carrier.qualimat_carrier_id IS 'Lien vers le référentiel QUALIMAT (saisie assistée RG-4.01). FK nullable ON DELETE SET NULL : transporteur conservé si la ligne QUALIMAT disparaît.'");
|
||||
$this->addSql("COMMENT ON COLUMN carrier.certification_type IS 'Type de certification : QUALIMAT (si lié, lecture seule) ou GMP_PLUS/OVOCOM/COMPTE_PROPRE/AUTRE. AUTRE déclenche le champ Décharge (RG-4.02).'");
|
||||
$this->addSql("COMMENT ON COLUMN carrier.is_chartered IS '« Affréter » coché : déclenche indexation/benne-fond mouvant/volume, obligatoires (RG-4.03).'");
|
||||
$this->addSql("COMMENT ON COLUMN carrier.liot_plates IS 'Immatriculations LIOT séparées par « ; » (cas spécial nom=LIOT, RG-4.01). Les autres champs sont masqués dans ce cas.'");
|
||||
$this->addSql("COMMENT ON COLUMN carrier_price.direction IS 'Sens du prix : CLIENT ou FOURNISSEUR (RG-4.09). Pilote l''affichage et l''obligation des colonnes client_*/supplier_* (RG-4.10/4.11).'");
|
||||
$this->addSql("COMMENT ON COLUMN carrier_price.departure_site_id IS 'Adresse de départ = un des 3 sites (86/17/82). FK -> site.id. Branche CLIENT (RG-4.10).'");
|
||||
$this->addSql("COMMENT ON COLUMN carrier_price.price_state IS 'État du prix : EN_COURS, VALIDE ou NON_VALIDE. Affiché dans le tableau Prix (regroupement Benne/Fond mouvant).'");
|
||||
// + COMMENT ON COLUMN sur TOUTES les autres colonnes métier (règle n°12)
|
||||
$this->addStandardTimestampableBlamableComments($schema, 'carrier');
|
||||
$this->addStandardTimestampableBlamableComments($schema, 'carrier_address');
|
||||
$this->addStandardTimestampableBlamableComments($schema, 'carrier_contact');
|
||||
$this->addStandardTimestampableBlamableComments($schema, 'carrier_price');
|
||||
```
|
||||
|
||||
### 3.3 Entité `Carrier` — squelette (extrait)
|
||||
|
||||
Pattern jumeau de `Supplier`/`Provider` (`#[Auditable]`, `TimestampableBlamableTrait`, sous-collections embarquées au détail). **Chaque propriété affichée porte un read-group** (RETEX M1 maillon (a)).
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagée (§ 2.1) — via carrier_price
|
||||
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierProcessor;
|
||||
use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\CarrierProvider;
|
||||
use App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('transport.carriers.view')",
|
||||
normalizationContext: ['groups' => ['carrier:read', 'qualimat:read', 'default:read']],
|
||||
provider: CarrierProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('transport.carriers.view')",
|
||||
normalizationContext: ['groups' => [
|
||||
'carrier:read', 'carrier:item:read', 'qualimat:read',
|
||||
'client:read', 'client_address:read',
|
||||
'supplier:read', 'supplier_address:read',
|
||||
'site:read', 'default:read',
|
||||
]],
|
||||
provider: CarrierProvider::class,
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => ['carrier:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['carrier:write:main']],
|
||||
processor: CarrierProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => ['carrier:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['carrier:write:main', 'carrier:write:archive']],
|
||||
provider: CarrierProvider::class,
|
||||
processor: CarrierProcessor::class,
|
||||
),
|
||||
// Pas de Delete au M4 (HP). Archivage via PATCH { isArchived: true }.
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineCarrierRepository::class)]
|
||||
#[ORM\Table(name: 'carrier')]
|
||||
#[Auditable]
|
||||
class Carrier implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'bigint')]
|
||||
#[Groups(['carrier:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'Le nom du transporteur est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(min: 2, max: 255, normalizer: 'trim')]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $name = null;
|
||||
|
||||
/** Lien référentiel QUALIMAT (saisie assistée RG-4.01). */
|
||||
#[ORM\ManyToOne(targetEntity: QualimatCarrier::class)]
|
||||
#[ORM\JoinColumn(name: 'qualimat_carrier_id', nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?QualimatCarrier $qualimatCarrier = null;
|
||||
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
#[Assert\Choice(choices: ['QUALIMAT', 'GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'],
|
||||
message: 'Type de certification invalide.')]
|
||||
// Obligatoire SAUF en cas LIOT (champ masqué) — contrôle conditionnel via #[Assert\Callback] (RG-4.01).
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $certificationType = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private bool $isChartered = false;
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 5, scale: 2, nullable: true)]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $indexationRate = null; // % — obligatoire si isChartered (RG-4.03, Callback)
|
||||
|
||||
#[ORM\Column(length: 12, nullable: true)]
|
||||
#[Assert\Choice(choices: ['BENNE', 'FOND_MOUVANT'], message: 'Type de contenant invalide.')]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $containerType = null; // obligatoire si isChartered (RG-4.03)
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, nullable: true)]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $volumeM3 = null; // obligatoire si isChartered (RG-4.03)
|
||||
|
||||
/** Décharge (upload, visible si certificationType = AUTRE — RG-4.02). Infra upload Shared (§ 2.7). */
|
||||
#[ORM\ManyToOne(targetEntity: \App\Shared\Domain\Entity\UploadedDocument::class)]
|
||||
#[ORM\JoinColumn(name: 'discharge_document_id', nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?UploadedDocument $dischargeDocument = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $liotPlates = null; // cas LIOT (RG-4.01)
|
||||
|
||||
// === Sous-collections — EMBARQUÉES dans le DÉTAIL ===
|
||||
/** @var Collection<int, CarrierAddress> */
|
||||
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private Collection $addresses;
|
||||
|
||||
/** @var Collection<int, CarrierContact> */
|
||||
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private Collection $contacts;
|
||||
|
||||
/** @var Collection<int, CarrierPrice> */
|
||||
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierPrice::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private Collection $prices;
|
||||
|
||||
// === Archive / Soft delete ===
|
||||
#[ORM\Column(name: 'is_archived', options: ['default' => false])]
|
||||
private bool $isArchived = false;
|
||||
|
||||
// ⚠ PIÈGE BOOLÉEN (RETEX M1 bug #3) : #[Groups] + #[SerializedName('isArchived')] SUR LE GETTER.
|
||||
#[Groups(['carrier:read', 'carrier:write:archive'])]
|
||||
#[SerializedName('isArchived')]
|
||||
public function isArchived(): bool
|
||||
{
|
||||
return $this->isArchived;
|
||||
}
|
||||
|
||||
// RG-4.02 / RG-4.03 / cas LIOT : cohérence inter-champs via #[Assert\Callback] (§ 7).
|
||||
// ... archivedAt, getters/setters, __construct (ArrayCollection) ...
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Squelettes des autres entités
|
||||
|
||||
**`CarrierAddress`** — propriétés dans `['carrier:item:read', 'carrier:write:addresses']` :
|
||||
`country`, `postalCode`, `city`, `street`, `streetComplement`, `id`. Saisie assistée BAN (RG-4.06). Pour un transporteur QUALIMAT, une adresse est **pré-remplie depuis la copie** (RG-4.05) et le bouton « Valider » de l'onglet est masqué (RG-4.07).
|
||||
|
||||
**`CarrierContact`** — propriétés dans `['carrier:item:read', 'carrier:write:contacts']` :
|
||||
`firstName`, `lastName`, `jobTitle`, `phonePrimary`, `phoneSecondary`, `email`, `id`. **Max 2 téléphones** (`phonePrimary` + `phoneSecondary`). RG-4.08 (≥ 1 champ rempli).
|
||||
|
||||
**`CarrierPrice`** — propriétés dans `['carrier:item:read', 'carrier:write:prices']` :
|
||||
`direction`, `client` (ManyToOne `Client`, embed `client:read`), `clientDeliveryAddress` (ManyToOne `ClientAddress`, embed `client_address:read`), `departureSite` (ManyToOne `Site`, `site:read`), `supplier` (ManyToOne `Supplier`, `supplier:read`), `supplierSupplyAddress` (ManyToOne `SupplierAddress`, embed `supplier_address:read`), `deliverySite` (`site:read`), `containerType`, `pricingUnit`, `price`, `priceState`, `id`. Relations cross-module **embarquées** (maillon (c) — read-groups `client:read`/`client_address:read`/`supplier:read`/`supplier_address:read`/`site:read` dans le contexte du `Get` racine).
|
||||
|
||||
**`QualimatCarrier`** (NOUVEAU mapping ORM sur la table existante `qualimat_carrier`) — entité **lecture seule** exposée pour la saisie assistée (§ 4.7). Propriétés sous `qualimat:read` : `id`, `siret`, `name`, `address`, `postalCode`, `city`, `phone`, `department`, `status`, `validityDate`, `isActive`. **Aucune écriture exposée** (alimentée par la commande console `app:qualimat:sync`).
|
||||
|
||||
> ⚠ `Client` / `Supplier` / `Site` appartiennent à d'autres modules — on consomme leurs read-groups (`client:read`, `supplier:read`, `site:read`), **pas de logique inter-module** (§ 2.1).
|
||||
|
||||
## 4. API REST (API Platform)
|
||||
|
||||
### 4.0 Contrat de sérialisation (RETEX M1 — section critique)
|
||||
|
||||
> **Leçon M1/M2/M3** : ~80 % des frictions venaient du contrat de sérialisation. Pour **chaque champ affiché** (liste OU détail), les **3 maillons** doivent être prouvés : (a) groupe sur la propriété, (b) groupe dans le `normalizationContext` de l'opération, (c) read-group de l'entité imbriquée présent dans le contexte parent.
|
||||
|
||||
**Contexte par opération** :
|
||||
|
||||
| Opération | `normalizationContext` (groupes) |
|
||||
|---|---|
|
||||
| `GetCollection` (liste) | `carrier:read` + `qualimat:read` + `default:read` |
|
||||
| `Get` (détail) | `carrier:read` + `carrier:item:read` + `qualimat:read` + `client:read` + `client_address:read` + `supplier:read` + `supplier_address:read` + `site:read` + `default:read` |
|
||||
|
||||
**LISTE — champ datatable → maillons** :
|
||||
|
||||
| Champ affiché | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) |
|
||||
|---|---|---|---|
|
||||
| Nom | `name` ∈ `carrier:read` | ✅ | — |
|
||||
| Certification | `certificationType` ∈ `carrier:read` | ✅ | — |
|
||||
| Date de validité (QUALIMAT) | `qualimatCarrier.validityDate` ∈ `carrier:read` (embed) | ✅ | `qualimat:read` ✅ (RG-4.04) |
|
||||
| Dernière activité | `updatedAt` ∈ `carrier:read` | ✅ | — |
|
||||
|
||||
**DÉTAIL — bloc → maillons** :
|
||||
|
||||
| Bloc / champ | Propriété (a) | Dans contexte détail (b) | Imbriqué (c) |
|
||||
|---|---|---|---|
|
||||
| Scalaires principaux | `carrier:read` | ✅ | — |
|
||||
| `qualimatCarrier` (statut/validité) | `qualimatCarrier` ∈ `carrier:read` | ✅ | `qualimat:read` ✅ |
|
||||
| `addresses[]` | `addresses` ∈ `carrier:item:read` | ✅ | propriétés `CarrierAddress` ∈ `carrier:item:read` ✅ |
|
||||
| `contacts[]` | `contacts` ∈ `carrier:item:read` | ✅ | propriétés `CarrierContact` ∈ `carrier:item:read` ✅ |
|
||||
| `prices[]` (scalaires) | `prices` ∈ `carrier:item:read` | ✅ | propriétés `CarrierPrice` ∈ `carrier:item:read` ✅ |
|
||||
| `prices[].client` | `client` ∈ `carrier:item:read` | ✅ | `client:read` ✅ |
|
||||
| `prices[].clientDeliveryAddress` | ∈ `carrier:item:read` | ✅ | `client_address:read` ✅ (entité `ClientAddress`) |
|
||||
| `prices[].supplier` | `supplier` ∈ `carrier:item:read` | ✅ | `supplier:read` ✅ |
|
||||
| `prices[].supplierSupplyAddress` | ∈ `carrier:item:read` | ✅ | `supplier_address:read` ✅ (entité `SupplierAddress`) |
|
||||
| `prices[].departureSite` / `.deliverySite` | ∈ `carrier:item:read` | ✅ | `site:read` ✅ |
|
||||
|
||||
### 4.0.bis Réponses JSON de référence (DoD — à CAPTURER sur l'API réelle)
|
||||
|
||||
> **Definition of Done** (miroir M2/M3) : avant de démarrer les écrans front, **capturer les réponses RÉELLES** via un test PHPUnit (`CarrierSerializationContractTest`, transporteur complet seedé) et les coller ici. Toute donnée affichée par le front DOIT apparaître dans ce JSON. **Ne jamais déclarer un champ « embarqué » sans l'avoir vu dans un JSON réel.**
|
||||
>
|
||||
> **Pièges hérités à re-tester sur le M4** :
|
||||
> 1. `prices[].client` / `.supplier` / `.departureSite` doivent sortir en **objet embarqué**, pas en IRI nu → vérifier les read-groups `client:read`/`supplier:read`/`site:read`.
|
||||
> 2. Sérialisation booléen `isArchived` (bug #3 M1) : clé présente dans le JSON réel.
|
||||
> 3. `qualimatCarrier` embarqué (statut + validité) pour RG-4.04.
|
||||
|
||||
> ✅ **CAPTURÉ (WT3, ERP-155/157)** — JSON réel produit par `CarrierSerializationContractTest` (transporteur complet seedé : lien QUALIMAT, 1 adresse, 1 contact, 2 prix CLIENT + FOURNISSEUR). Les 3 pièges sont vérifiés verts. **Le front peut démarrer sur ce contrat.**
|
||||
>
|
||||
> Contraintes d'architecture validées au passage :
|
||||
> - Relations cross-module des prix (`client`/`supplier`/adresses) câblées **sans import inter-module** (règle n°1) via des contrats `Shared/Domain/Contract/*Interface` + `resolve_target_entities`. L'embed JSON passe par les read-groups des entités concrètes (`client:read`, `client_address:read`, `supplier:read`, `supplier_address:read`, `site:read`). Un groupe `supplier_address:read` a été **ajouté aux champs scalaires de `SupplierAddress`** (M2) pour que `supplierSupplyAddress` s'embarque comme `clientDeliveryAddress` (M1 avait déjà `client_address:read`).
|
||||
> - `QualimatCarrier` = mapping ORM **lecture seule** sur la table référentielle existante (sortie du `schema_filter`, mapping aligné au DDL ERP-39 → `schema:update` no-op).
|
||||
|
||||
**`GET /api/carriers?search=…` (LISTE)** — enveloppe Hydra AP4 (`member`/`totalItems`/`view` sans préfixe `hydra:`), archivés exclus par défaut (`?includeArchived=true` les réintègre) :
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"@context": "/api/contexts/Carrier", "@id": "/api/carriers", "@type": "Collection",
|
||||
"totalItems": 1,
|
||||
"member": [
|
||||
{
|
||||
"@id": "/api/carriers/12", "@type": "Carrier", "id": 12,
|
||||
"name": "TRANSPORTS GRELILLIER",
|
||||
"qualimatCarrier": { // embarqué (objet), pas IRI — RG-4.04
|
||||
"@id": "/api/qualimat_carriers/8", "@type": "QualimatCarrier", "id": "8",
|
||||
"siret": "…", "name": "…", "address": "…", "postalCode": "86000", "city": "Poitiers",
|
||||
"status": "Valide", "validityDate": "2027-12-31T00:00:00+01:00"
|
||||
},
|
||||
"certificationType": "QUALIMAT",
|
||||
"createdAt": "…", "updatedAt": "…",
|
||||
"isChartered": false, // bool présent (getter + SerializedName)
|
||||
"isArchived": false // bool présent (piège #3)
|
||||
}
|
||||
],
|
||||
"view": { "@id": "/api/carriers?search=…", "@type": "PartialCollectionView" }
|
||||
}
|
||||
```
|
||||
|
||||
**`GET /api/carriers/{id}` (DÉTAIL)** — `qualimatCarrier` + `addresses[]` + `contacts[]` + `prices[]` avec relations cross-module embarquées en objet :
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"@id": "/api/carriers/12", "@type": "Carrier", "id": 12,
|
||||
"name": "TRANSPORTS GRELILLIER",
|
||||
"qualimatCarrier": { "@type": "QualimatCarrier", "status": "Valide", "validityDate": "…", "...": "…" },
|
||||
"certificationType": "QUALIMAT",
|
||||
"addresses": [
|
||||
{ "@type": "CarrierAddress", "id": 4, "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "…", "createdAt": "…", "updatedAt": "…" }
|
||||
],
|
||||
"contacts": [
|
||||
{ "@type": "CarrierContact", "id": 5, "firstName": "Marie", "lastName": "Martin", "phonePrimary": "0612345678", "email": "…", "createdAt": "…", "updatedAt": "…" }
|
||||
],
|
||||
"prices": [
|
||||
{
|
||||
"@type": "CarrierPrice", "id": 7, "direction": "CLIENT",
|
||||
"client": { "@type": "Client", "@id": "/api/clients/4", "id": 4, "companyName": "…", "isArchived": false, "...": "…" },
|
||||
"clientDeliveryAddress": { "@type": "ClientAddress", "@id": "/api/client_addresses/4", "postalCode": "86000", "city": "Poitiers", "street": "…", "...": "…" },
|
||||
"departureSite": { "@type": "Site", "@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "...": "…" },
|
||||
"containerType": "BENNE", "pricingUnit": "TONNE", "price": "42.50", "priceState": "VALIDE",
|
||||
"createdAt": "…", "updatedAt": "…"
|
||||
},
|
||||
{
|
||||
"@type": "CarrierPrice", "id": 8, "direction": "FOURNISSEUR",
|
||||
"supplier": { "@type": "Supplier", "@id": "/api/suppliers/4", "id": 4, "companyName": "…", "isArchived": false, "...": "…" },
|
||||
"supplierSupplyAddress": { "@type": "SupplierAddress", "@id": "/api/supplier_addresses/38", "id": 38, "addressType": "DEPART", "country": "France", "postalCode": "17000", "city": "La Rochelle", "street": "…" },
|
||||
"deliverySite": { "@type": "Site", "@id": "/api/sites/1", "name": "Chatellerault", "...": "…" },
|
||||
"containerType": "FOND_MOUVANT", "pricingUnit": "FORFAIT", "price": "320.00", "priceState": "EN_COURS",
|
||||
"createdAt": "…", "updatedAt": "…"
|
||||
}
|
||||
],
|
||||
"createdAt": "…", "updatedAt": "…",
|
||||
"isChartered": false, "isArchived": false
|
||||
}
|
||||
```
|
||||
|
||||
> Note WT3 : opérations exposées = `GetCollection` + `Get` (lecture). `POST`/`PATCH` (+ `CarrierProcessor`, normalisation, RG-4.01→4.14, 409 doublon, gating archive) et les sous-ressources d'écriture (adresses/contacts/prix) arrivent aux worktrees suivants (WT4+).
|
||||
|
||||
### 4.1 `GET /api/carriers` — Liste
|
||||
|
||||
- **Security** : `is_granted('transport.carriers.view')`
|
||||
- **Query params** (alimentent le panneau « Filtrer ») :
|
||||
- `includeArchived=true|false` (default `false`)
|
||||
- `certificationType=<code>` (filtre ; répétable)
|
||||
- `search=<text>` (fuzzy sur `name`)
|
||||
- **Tri par défaut** : `name ASC`
|
||||
- **Pagination** : standard Starseed (règle ABSOLUE n°13) — Hydra, 10/page, `?pagination=false` pour les selects. `CarrierProvider` branché sur `ApiPlatform\Doctrine\Orm\Paginator`.
|
||||
- **Pas de cloisonnement par site** (§ 2.3) : tout user `view` voit tous les transporteurs.
|
||||
- **Codes** : `200` / `401` / `403`
|
||||
|
||||
### 4.2 `GET /api/carriers/{id}` — Détail
|
||||
|
||||
- **Security** : `is_granted('transport.carriers.view')`
|
||||
- **Comportement** : transporteur + `qualimatCarrier` + `addresses` + `contacts` + `prices` (avec `client`/`supplier`/sites embarqués).
|
||||
- **Codes** : `200` / `404` / `401` / `403`
|
||||
|
||||
### 4.3 `POST /api/carriers` — Création (formulaire principal)
|
||||
|
||||
- **Security** : `is_granted('transport.carriers.manage')`
|
||||
- **Body** (groupe `carrier:write:main`) — exemple QUALIMAT :
|
||||
```json
|
||||
{
|
||||
"name": "TRANSPORTS GRELILLIER",
|
||||
"qualimatCarrier": "/api/qualimat_carriers/142",
|
||||
"certificationType": "QUALIMAT",
|
||||
"isChartered": false
|
||||
}
|
||||
```
|
||||
- **Body** — exemple non-QUALIMAT affrété :
|
||||
```json
|
||||
{
|
||||
"name": "TRANSPORTS PANDELE",
|
||||
"certificationType": "AUTRE",
|
||||
"isChartered": true,
|
||||
"indexationRate": "5.00",
|
||||
"containerType": "BENNE",
|
||||
"volumeM3": "90.00",
|
||||
"dischargeDocument": "/api/uploaded_documents/12"
|
||||
}
|
||||
```
|
||||
- **Réponse 201** : le transporteur créé avec son `id`. Le front enchaîne les PATCH / sous-ressources par onglet.
|
||||
- **Codes** : `201` / `400` / `401` / `403`
|
||||
- `409 Conflict` si doublon de nom (`name` — RG-4.12).
|
||||
- `422` : RG-4.02 (AUTRE sans décharge → obligatoire, voir § 7), RG-4.03 (affrété sans indexation/benne/volume), certification invalide, cas LIOT incohérent.
|
||||
|
||||
### 4.4 `PATCH /api/carriers/{id}` — Modification
|
||||
|
||||
- **Security base** : `is_granted('transport.carriers.manage')`
|
||||
- **Security additionnelle** (dans le `CarrierProcessor`) :
|
||||
- payload contenant `isArchived` → exige `transport.carriers.archive` (Admin seul).
|
||||
- **mode strict** (RG-4.14) : payload mélangeant un champ archive sans la permission → 403 sur tout le payload.
|
||||
- **Body** : merge-patch+json, champs modifiés uniquement.
|
||||
- **Codes** : `200` / `400` / `401` / `403` / `404` / `409` / `422`
|
||||
|
||||
### 4.5 Sous-ressources
|
||||
|
||||
**Adresses** : `POST /api/carriers/{id}/addresses`, `PATCH /api/carrier_addresses/{id}`, `DELETE /api/carrier_addresses/{id}`.
|
||||
- **Security** : `is_granted('transport.carriers.manage')`
|
||||
- RG-4.05 (pré-remplissage QUALIMAT), RG-4.06 (autocomplete BAN), RG-4.07 (pas de validation manuelle si QUALIMAT — front).
|
||||
|
||||
**Contacts** : `POST /api/carriers/{id}/contacts`, `PATCH /api/carrier_contacts/{id}`, `DELETE /api/carrier_contacts/{id}`.
|
||||
- **Security** : `is_granted('transport.carriers.manage')`
|
||||
- RG-4.08 : ≥ 1 champ rempli (CHECK BDD + Processor). Max 2 téléphones.
|
||||
|
||||
**Prix** : `POST /api/carriers/{id}/prices`, `PATCH /api/carrier_prices/{id}`, `DELETE /api/carrier_prices/{id}`.
|
||||
- **Security** : `is_granted('transport.carriers.manage')`
|
||||
- RG-4.09 → RG-4.11 : cohérence branche CLIENT vs FOURNISSEUR (Processor + CHECK). `client_delivery_address` doit appartenir au `client` choisi ; `supplier_supply_address` au `supplier` choisi → sinon 422.
|
||||
|
||||
### 4.6 Export
|
||||
|
||||
**Répertoire** : `GET /api/carriers/export.xlsx`
|
||||
- **Security** : `is_granted('transport.carriers.view')`
|
||||
- **Comportement** : XLSX des transporteurs **affichés** (mêmes filtres que la liste, non archivés par défaut).
|
||||
- Colonnes : Nom, Certification, Statut QUALIMAT, Date de validité, Affrété, Volume m³, Date de création.
|
||||
|
||||
**Onglet Prix** : `GET /api/carriers/{id}/prices/export.xlsx`
|
||||
- **Security** : `is_granted('transport.carriers.view')`
|
||||
- **Comportement** : le tableau Prix regroupé par type (Fond Mouvant / Benne) — colonnes du docx p.10 : Transporteurs, Adresse APRO ou Adresse Sites, Adresse livraisons, Forfait €, Tonne €, Indexation, État du prix.
|
||||
- **Implémentation** : controller custom `CarrierExportController` / `CarrierPriceExportController` avec `#[Route(priority: 1)]` (règle ABSOLUE — conflit API Platform `{id}`). Lib : PhpSpreadsheet (déjà présente).
|
||||
- **Réponse 200** : `Content-Disposition: attachment; filename="...-{YYYYMMDD}.xlsx"`
|
||||
|
||||
### 4.7 Référentiel QUALIMAT — endpoint de recherche (NOUVEAU, lecture seule)
|
||||
|
||||
`GET /api/qualimat_carriers?search=<texte>` — alimente la **saisie assistée** du nom (RG-4.01).
|
||||
- **Security** : `is_granted('transport.carriers.view')`
|
||||
- **Comportement** : recherche fuzzy sur `name` (+ `siret`), **seulement les lignes actives** (`is_active = true`), triées par `name`. Paginé (règle n°13).
|
||||
- **Mapping ORM** : nouvelle entité `QualimatCarrier` (lecture seule) sur la table existante `qualimat_carrier`. **Aucune** opération `Post`/`Patch`/`Delete` (alimentée par `app:qualimat:sync`).
|
||||
- Réutilisé aussi par le front pour la copie des champs adresse à la sélection (RG-4.01 / RG-4.05).
|
||||
|
||||
### 4.8 Référentiels Prix (réutilisés M1/M2)
|
||||
|
||||
`GET /api/clients`, `/api/suppliers`, leurs adresses (`/api/clients/{id}` embarque les adresses, ou endpoint adresses dédié), `GET /api/sites` (3 sites) : **existent déjà** (M1/M2). **Évolution M4** : élargir leur `security` pour autoriser aussi `transport.carriers.manage` (selects de l'onglet Prix), p.ex. `... or is_granted('transport.carriers.manage')`. Pas d'écriture exposée par le M4.
|
||||
|
||||
## 5. Autorisation
|
||||
|
||||
### 5.1 Déclaration des permissions
|
||||
|
||||
Remplir `TransportModule::permissions()` (actuellement `[]`) :
|
||||
|
||||
```php
|
||||
['code' => 'transport.carriers.view', 'label' => 'Voir les transporteurs'],
|
||||
['code' => 'transport.carriers.manage', 'label' => 'Créer / modifier les transporteurs'],
|
||||
['code' => 'transport.carriers.archive', 'label' => 'Archiver / restaurer un transporteur'],
|
||||
```
|
||||
|
||||
Synchronisation : `php bin/console app:sync-permissions`.
|
||||
|
||||
### 5.2 Mapping rôles MALIO ↔ permissions (docx « Rôles & permissions »)
|
||||
|
||||
| Permission | Admin | Bureau | Compta | Commerciale | Usine |
|
||||
|---|---|---|---|---|---|
|
||||
| `transport.carriers.view` | ✅ | ✅ | ❌ | ✅ | ❌ |
|
||||
| `transport.carriers.manage` | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| `transport.carriers.archive` | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
- **Admin** : tout (view + manage + archive).
|
||||
- **Bureau** : view + manage (pas d'archive).
|
||||
- **Commerciale** : view seul (consultation « Tout », pas de création/modification).
|
||||
- **Compta / Usine** : aucun accès au module (ni view ni manage).
|
||||
|
||||
### 5.3 Synchronisation RBAC (3 sources OBLIGATOIRES — règle ABSOLUE Starseed n°8)
|
||||
|
||||
1. **`config/sidebar.php`** — **nouvelle section « Transport »** (ou rattachement à une section « Logistique » existante — *à confirmer*) + item :
|
||||
```php
|
||||
[
|
||||
'key' => 'transport',
|
||||
'label' => 'sidebar.transport.section',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.transport.carriers',
|
||||
'to' => '/carriers',
|
||||
'icon' => 'mdi:truck-outline',
|
||||
'module' => 'transport',
|
||||
'permission' => 'transport.carriers.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
```
|
||||
|
||||
2. **`frontend/tests/e2e/_fixtures/personas.ts`** — étendre les personas existants :
|
||||
- Admin : `view` + `manage` + `archive`
|
||||
- Bureau : `view` + `manage`
|
||||
- Commerciale : `view`
|
||||
- Compta / Usine : **aucune** permission `transport.carriers.*` (vérifier 403)
|
||||
|
||||
3. **`src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`** — miroir back des mêmes personas.
|
||||
|
||||
> ⚠ Les 3 sources doivent être touchées dans le **même commit** (sinon drift / test cassé).
|
||||
|
||||
### 5.4 Vérification front
|
||||
|
||||
- `usePermissions()` filtre l'item sidebar (`transport.carriers.view`).
|
||||
- Bouton « + Ajouter » / « Modifier » visibles si `transport.carriers.manage`.
|
||||
- Bouton « Archiver » visible si `transport.carriers.archive` (Admin seul).
|
||||
|
||||
## 6. Audit & dates
|
||||
|
||||
- `Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice` : `#[Auditable]`, tous champs audités.
|
||||
- Timestampable + Blamable : pattern Shared standard.
|
||||
- `QualimatCarrier` / `UploadedDocument` : voir leur propre cycle (référentiel synchro / upload).
|
||||
- Libellés i18n `audit.entity.transport_*` (§ 2.8).
|
||||
|
||||
## 7. Règles de gestion (RG)
|
||||
|
||||
> RG-4.01 → RG-4.11 reprennent le docx source. RG-4.12 → RG-4.14 sont des **précisions back** explicitement marquées.
|
||||
|
||||
### Formulaire principal
|
||||
|
||||
- **RG-4.01** _(saisie assistée QUALIMAT + cas LIOT)_ : le nom est saisi par l'utilisateur, ce qui déclenche une recherche dans le référentiel QUALIMAT (`GET /api/qualimat_carriers?search=`). Sélection d'un transporteur → modal de confirmation (front) → copie de `name` + `certificationType = QUALIMAT` + adresse (§ 2.5). **FK** `qualimatCarrier` conservée. **Cas non trouvé** : pas QUALIMAT → l'utilisateur choisit une autre certification (RG-4.02). **Cas LIOT (décision Matthieu, 15/06)** : si le nom saisi est exactement `LIOT`, le champ `liotPlates` apparaît (immatriculations séparées par `;`) et **les autres champs sont masqués** (certification, affrètement, décharge…). Conséquences back : (a) `certificationType` n'est **pas requis** en cas LIOT (nullable — le select est masqué) et **reste obligatoire** pour tous les autres cas (contrôle conditionnel `#[Assert\Callback]`) ; (b) `isChartered`/`indexationRate`/`containerType`/`volumeM3`/`dischargeDocument` ignorés/laissés nuls ; (c) le back stocke ce qu'il reçoit, pas de 422 sur la présence résiduelle d'un autre champ (cohérence d'affichage portée par le front).
|
||||
- **RG-4.02** _(certification AUTRE → Décharge **obligatoire**)_ : si `certificationType = 'AUTRE'`, le champ Décharge (`dischargeDocument`) **apparaît et est obligatoire**. Validation server-side (`#[Assert\Callback]` dans le `CarrierProcessor`) : `certificationType = 'AUTRE'` et `dischargeDocument IS NULL` → **422** sur `dischargeDocument`. En base, `discharge_document_id` reste **nullable** (null pour les autres certifications) ; c'est la contrainte conditionnelle qui impose le fichier quand AUTRE.
|
||||
- **RG-4.03** _(Affréter)_ : si `isChartered = true`, les champs `indexationRate`, `containerType` (Benne/Fond mouvant) et `volumeM3` deviennent **visibles et obligatoires**. Validation server-side (`#[Assert\Callback]`) : `isChartered = true` et l'un des trois `NULL` → **422** sur le champ concerné.
|
||||
|
||||
### Onglet Adresse
|
||||
|
||||
- **RG-4.04** _(date de validité QUALIMAT)_ : la `validityDate` du `qualimatCarrier` lié, si **antérieure à aujourd'hui**, est affichée **sur fond rouge** (front). Donnée exposée via `qualimatCarrier.validityDate` (§ 4.0).
|
||||
- **RG-4.05** _(pré-remplissage QUALIMAT)_ : les champs adresse sont déjà remplis si le transporteur est QUALIMAT (copie § 2.5). Si « Affréter » est coché, l'adresse devient obligatoire (Pays, Code postal, Ville, Adresse). Validation : `Assert\Callback` conditionnelle.
|
||||
- **RG-4.06** _(autocomplete BAN)_ : `city` préremplie depuis `postalCode` via l'API **BAN** (api-adresse.data.gouv.fr), appel **direct front** via `useAddressAutocomplete()` (réutilisé M1/M2/M3). Validation serveur : `postalCode` matche `^[0-9]{4,5}$` ; pas de contrôle strict CP/Ville.
|
||||
- **RG-4.07** _(pas de validation manuelle si QUALIMAT)_ : le bouton « Valider » de l'onglet Adresse n'apparaît pas pour un transporteur QUALIMAT (adresse remplie automatiquement). Règle **front** ; back accepte le PATCH adresse normalement.
|
||||
|
||||
### Onglet Contact
|
||||
|
||||
- **RG-4.08** _(bloc Contact valide)_ : un bloc Contact est valide dès qu'**au moins 1 champ** est rempli. CHECK BDD `chk_carrier_contact_filled` (garde-fou). UI : « + Nouveau contact » bloqué tant que le bloc en cours n'a aucun champ rempli. **Max 2 téléphones** par contact.
|
||||
|
||||
### Onglet Prix
|
||||
|
||||
- **RG-4.09** _(affichage conditionnel)_ : tous les champs masqués par défaut sauf le radio `direction` (Client / Fournisseur), qui déclenche l'affichage des bons champs.
|
||||
- **RG-4.10** _(branche CLIENT)_ : si `direction = CLIENT`, les champs `client`, `clientDeliveryAddress` (liste des adresses du client sélectionné), `departureSite` (86/17/82) sont **affichés et obligatoires** ; les champs fournisseur sont masqués/nuls. CHECK `chk_carrier_price_client_branch` + validation Processor (`clientDeliveryAddress` appartient à `client` → sinon 422).
|
||||
- **RG-4.11** _(branche FOURNISSEUR)_ : si `direction = FOURNISSEUR`, les champs `supplier`, `supplierSupplyAddress` (adresses du fournisseur), `deliverySite` (86/17/82) sont **affichés et obligatoires** ; les champs client masqués/nuls. CHECK `chk_carrier_price_supplier_branch` + validation Processor (`supplierSupplyAddress` appartient à `supplier`).
|
||||
- Champs communs **toujours obligatoires** : `containerType` (Benne/Fond mouvant), `pricingUnit` (Forfait/Tonne), `price` (monnaie), `priceState` (En cours / Validé / Non validé).
|
||||
|
||||
### Précisions back
|
||||
|
||||
- **RG-4.12** _(unicité nom)_ : `name` unique (case-insensitive) parmi les transporteurs non archivés ET non soft-deletés (index partiel `uq_carrier_name_active`). Doublon → 409 « Un transporteur nommé "{name}" existe déjà. »
|
||||
- **RG-4.13** _(normalisation serveur)_ : `name` **UPPERCASE** ; `firstName`/`lastName` (sur `CarrierContact`) **Capitalize** ; téléphones **chiffres uniquement** ; `email` **lowercase** ; `liotPlates` normalisé (`;`-split, trim, UPPER). Formatage à l'affichage front.
|
||||
- **RG-4.14** _(archivage + mode strict)_ : PATCH `{ "isArchived": true }` exige `transport.carriers.archive` (**Admin seul**) → `isArchived = true` + `archivedAt = now()`. PATCH `{ "isArchived": false }` restaure (conflit d'unicité de nom → 409). Un PATCH mêlant archive sans permission → 403 sur tout le payload.
|
||||
|
||||
## 8. Tests à automatiser
|
||||
|
||||
### 8.1 Cas à couvrir (back — PHPUnit)
|
||||
|
||||
- [ ] **RG-4.01** : POST avec `qualimatCarrier` → `certificationType=QUALIMAT` accepté + FK persistée ; `GET /api/qualimat_carriers?search=` ne renvoie que les lignes actives
|
||||
- [ ] **RG-4.02** : POST `certificationType=AUTRE` sans `dischargeDocument` → 422 ; avec décharge → 201 ; certification ≠ AUTRE sans décharge → 201
|
||||
- [ ] **RG-4.03** : POST `isChartered=true` sans `indexationRate`/`containerType`/`volumeM3` → 422 ; complet → 201
|
||||
- [ ] **RG-4.05** : POST adresse pour transporteur affrété sans Pays/CP/Ville/Adresse → 422
|
||||
- [ ] **RG-4.06** : POST adresse `postalCode` invalide (3 chiffres) → 422 ; CP/ville incohérents → 200
|
||||
- [ ] **RG-4.08** : POST contact totalement vide → 422 (CHECK) ; 1 champ rempli → 200 ; 3e téléphone → 422
|
||||
- [ ] **RG-4.09/4.10** : POST prix `direction=CLIENT` sans `client`/`clientDeliveryAddress`/`departureSite` → 422 ; `clientDeliveryAddress` n'appartenant pas au `client` → 422 ; complet → 201
|
||||
- [ ] **RG-4.11** : POST prix `direction=FOURNISSEUR` symétrique ; `supplierSupplyAddress` étrangère au `supplier` → 422
|
||||
- [ ] **RG-4.12** : POST `name` déjà pris → 409 ; même nom après archivage de l'ancien → 201
|
||||
- [ ] **RG-4.13** : POST `name="transports x"` → persiste `"TRANSPORTS X"` ; normalisation contact/phone/email ; `liotPlates="ab-123-cd ; ef-456-gh"` normalisé
|
||||
- [ ] **RG-4.14** : PATCH isArchived=true par Bureau (sans `archive`) → 403 ; par Admin → 200 + `archivedAt` rempli ; restauration en conflit de nom → 409
|
||||
- [ ] **RBAC** : Admin/Bureau/Commerciale/Compta/Usine sur chaque permission (matrice § 5.2) — 200/403 selon le verbe (Compta + Usine : 403 sur view ET manage)
|
||||
- [ ] **🔴 Embed relations** : GET détail → `prices[].client`/`.supplier`/`.departureSite`/`.deliverySite` **objets embarqués** (pas IRI nu) ; `qualimatCarrier` embarqué (statut + validité)
|
||||
- [ ] **🔴 Sérialisation booléen (bug #3 M1)** : GET détail expose la clé `isArchived`
|
||||
- [ ] **Liste / tri** : `GET /api/carriers` exclut archivés par défaut ; `?includeArchived=true` inclut ; tri `name ASC`
|
||||
- [ ] **Anti N+1 liste (§ 2.11)** : nombre de requêtes SQL constant
|
||||
- [ ] **Export** : XLSX répertoire + XLSX onglet Prix (regroupé Benne/FM) — `Content-Disposition`
|
||||
- [ ] **Upload** (§ 2.7) : POST `/api/uploaded_documents` MIME hors whitelist → 422 ; MIME valide → IRI ; validation via `$file->getMimeType()` (pas `getClientMimeType()`)
|
||||
- [ ] **Audit** : POST + PATCH + archive → `audit_log` `entity_type='Carrier'`, `changes` correct
|
||||
- [ ] **Pagination** (règle n°13) : enveloppe Hydra (`totalItems`/`view`) ; `?pagination=false` renvoie tout (selects)
|
||||
- [ ] **Migration** : `make db-reset` → schéma OK ; namespace racine ; index partiel `uq_carrier_name_active` ; **toutes les colonnes ont un `COMMENT ON COLUMN`** (`ColumnsHaveSqlCommentTest` vert)
|
||||
- [ ] **i18n audit** : `audit.entity.transport_carrier`… présents (`AuditableEntitiesHaveI18nLabelTest` vert)
|
||||
|
||||
### 8.2 Cas à couvrir (front — Vitest)
|
||||
|
||||
- [ ] `usePaginatedList({url:'/carriers'})` : exclusion archivés par défaut, envelope Hydra
|
||||
- [ ] `useCarrierForm()` : workflow par onglet (validation incrémentale, PATCH partiel) ; champs conditionnels (Affréter, AUTRE→Décharge, LIOT)
|
||||
- [ ] Saisie assistée QUALIMAT : recherche → modal → copie nom/certif/adresse + FK
|
||||
- [ ] `useAddressAutocomplete()` : réutilisation M1/M2/M3 (nominal + dégradé)
|
||||
- [ ] Onglet Prix : bascule Client/Fournisseur (RG-4.09→4.11) ; date de validité fond rouge (RG-4.04)
|
||||
- [ ] `useFormErrors` : mapping 422 inline par champ (formulaire principal + blocs)
|
||||
- [ ] Permissions : Commerciale en lecture seule (pas de « + Ajouter »/« Modifier ») ; bouton Archiver visible Admin seul
|
||||
|
||||
### 8.3 Tests E2E
|
||||
|
||||
**Non prévus au M4** (règle ABSOLUE n°7). Extension des personas existants pour les permissions `transport.carriers.*` — cf. § 5.3.
|
||||
|
||||
### 8.4 Seed & fixtures démo (RETEX M1 §7 — prévu dès la spec)
|
||||
|
||||
`CarrierFixtures` idempotent couvrant les RG :
|
||||
- ≥ 1 transporteur **QUALIMAT** (lié à une ligne `qualimat_carrier` seedée, adresse copiée, `validityDate` passée pour tester RG-4.04) ;
|
||||
- 1 transporteur **AUTRE + Décharge** (RG-4.02) ; 1 **affrété** (indexation/benne/volume — RG-4.03) ; 1 **LIOT** (immatriculations) ;
|
||||
- ≥ 1 transporteur avec **contacts**, **adresses**, et **prix** des deux branches (CLIENT + FOURNISSEUR) ;
|
||||
- 1 transporteur **archivé** (exclusion liste + restauration).
|
||||
- Réutiliser les comptes de rôles démo (`admin`, `bureau`, `commerciale`, `compta`, `usine`).
|
||||
- Le seed QUALIMAT s'appuie sur la commande `app:qualimat:sync` (ou un mini-seed de `qualimat_carrier` en fixture de test, idempotent).
|
||||
|
||||
### 8.5 Checklist RETEX (à cocher avant « spec prête »)
|
||||
|
||||
- [x] 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0)
|
||||
- [x] Décision embed vs GetCollection explicite (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5)
|
||||
- [ ] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — à produire au ticket tests (`CarrierSerializationContractTest`)
|
||||
- [x] Matrice RBAC rôle × permission + mode strict archive (§ 5.2 / RG-4.14)
|
||||
- [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit + i18n, routes à plat : rappelés
|
||||
- [x] Réutilisations identifiées (référentiel QUALIMAT, Client/Supplier/Site partagés, `usePaginatedList`, blocs, archive, normalisation, `useAddressAutocomplete`)
|
||||
- [x] Seed/fixtures démo planifiés (§ 8.4)
|
||||
- [x] **Décisions tranchées (Matthieu, 15/06)** : lien QUALIMAT FK+copie (§ 2.5) ✅ ; pas de cloisonnement site (§ 2.3) ✅ ; infra upload Shared réutilisable (§ 2.7) ✅ ; unicité nom seul (§ 2.6) ✅
|
||||
|
||||
## 9. Hors-périmètre (HP)
|
||||
|
||||
- **HP-M4-A** : **Exploitation du référentiel IDTF** (`idtf_product`, ERP-149) dans les écrans transporteurs (régimes de nettoyage par marchandise). Synchronisé mais non consommé par le M4.
|
||||
- **HP-M4-B** : **Infra upload avancée** — antivirus, stockage objet (S3/MinIO), purge/rétention, prévisualisation. Le M4 livre l'infra **minimale** (§ 2.7).
|
||||
- **HP-M4-C** : **DELETE / soft delete d'un transporteur** (colonne `deleted_at` préparée, non exposée).
|
||||
- **HP-M4-D** : **Liaison transporteur ↔ tournées / expéditions** (modules logistiques futurs consommant `carrier_id`).
|
||||
- **HP-M4-E** : **Historisation des prix** (versionnage des `carrier_price`) — au M4, état simple (En cours/Validé/Non validé).
|
||||
- **HP-M4-F** : **Validation stricte SIRET / IBAN** (non applicable ici : pas de comptabilité au M4).
|
||||
- **HP-M4-G** : **Export CSV** (XLSX uniquement au M4).
|
||||
- **HP-M4-H** : **Onglets « À venir »** non détaillés par le docx → placeholders si présents en maquette.
|
||||
|
||||
## 10. Liens & dépendances
|
||||
|
||||
### Liens
|
||||
|
||||
- Spec front : [`./spec-front.md`](./spec-front.md)
|
||||
- Spec M2 fournisseurs (pattern de référence) : [`../M2-suppliers/spec-back.md`](../M2-suppliers/spec-back.md)
|
||||
- Spec M3 prestataires (pattern le plus proche) : [`../M3-prestataires/spec-back.md`](../M3-prestataires/spec-back.md)
|
||||
- Branches existantes : `feat/erp-150-module-transport` (module) · `feat/erp-39-qualimat-sync` (réf. QUALIMAT) · `feat/erp-149-idtf-sync` (réf. IDTF)
|
||||
- BAN api : `https://adresse.data.gouv.fr/api-doc/adresse`
|
||||
- Trace fonctionnelle : `M4-repertoire-transporteurs-V0.docx` / `.pdf` (V0, validé 27/05/2026)
|
||||
|
||||
### Dépendances amont (déjà en place dans Starseed)
|
||||
|
||||
- Module `Transport` : `qualimat_carrier` (réf. QUALIMAT, ERP-39) + `idtf_product` (réf. IDTF, ERP-149) + `TransportModule`
|
||||
- Module `Commercial` : `Client` (M1) + `Supplier` (M2) + leurs adresses (onglet Prix, relation ORM partagée)
|
||||
- Module `Sites` : `Site` (3 sites 86/17/82) — adresses départ/livraison du Prix
|
||||
- Module `Core` : `User`, `Role`, `Permission`, `Audit`, JWT
|
||||
- `Shared` : `TimestampableBlamableTrait` + `Subscriber` (+ NOUVELLE infra upload — § 2.7)
|
||||
- API Platform 4 + Doctrine ORM + PostgreSQL 16 + PhpSpreadsheet (export)
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tickets Lesstime (à découper)
|
||||
|
||||
**TaskGroup Lesstime** : à créer — `M4 — Répertoire transporteurs` (projet `ERP / Starseed`, projectId=6).
|
||||
|
||||
Ordre indicatif (back avant front, migration en tête) :
|
||||
0. **Permissions Transport + sidebar** — remplir `TransportModule::permissions()` (3 permissions) + section sidebar « Transport »/« Logistique » + sync 3 sources RBAC.
|
||||
1. **Infra upload générique `Shared`** (§ 2.7) — table `uploaded_document` + `FileUploader` (MIME serveur) + endpoint `POST /api/uploaded_documents`.
|
||||
2. **Migration BDD M4** (tables `carrier` + sous-collections + index partiel + CHECK + COMMENT ON COLUMN).
|
||||
3. **Entité `QualimatCarrier` (lecture seule)** + endpoint `GET /api/qualimat_carriers?search=` (RG-4.01).
|
||||
4. **Entités + Repositories** (`Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice`) + hydratation liste (§ 2.11).
|
||||
5. **CarrierProvider + CarrierProcessor** (normalisation, archivage, champs conditionnels RG-4.02/4.03, cas LIOT, mode strict).
|
||||
6. **Sous-ressources** (Addresses / Contacts / Prices Processors) + validations branches Prix (RG-4.10/4.11).
|
||||
7. **Export XLSX** (répertoire + onglet Prix regroupé Benne/FM) — controllers `priority:1`.
|
||||
8. **RBAC** : sync 3 sources + tests personas.
|
||||
9. **Tests PHPUnit** : matrice RG-4.01 → RG-4.14 (§ 8.1) + capture JSON réel (§ 4.0.bis).
|
||||
10. **Front** : page Répertoire (`/carriers`) + `usePaginatedList`.
|
||||
11. **Front** : page Ajouter (`/carriers/new`) + formulaire principal + saisie assistée QUALIMAT + champs conditionnels.
|
||||
12. **Front** : onglets Adresse (BAN) / Contact / Prix.
|
||||
13. **Front** : pages Consultation + Modification.
|
||||
14. **i18n + libellés audit** (`audit.entity.transport_*`).
|
||||
|
||||
### Actions manuelles dans Lesstime (Matthieu)
|
||||
|
||||
1. Créer le TaskGroup `M4 — Répertoire transporteurs` (projet ERP / Starseed, projectId=6).
|
||||
2. Créer les tickets ci-dessus avec dépendances séquentielles.
|
||||
3. Mettre à jour le frontmatter (`lesstime_taskgroup_id`) avec l'id réel.
|
||||
|
||||
### ✅ Décisions tranchées (Matthieu, 15/06/2026)
|
||||
|
||||
1. **Modèle Prix** (RG-4.10/4.11, § 3.2) — « Adresse de départ » / « Adresse de livraison » 86/17/82 = les 3 `Site` (FK `site`) ; « Adresse de livraison du client » = `ClientAddress` (M1) ; « Adresse d'approvisionnement » = `SupplierAddress` (M2). ✅
|
||||
2. **Lien QUALIMAT** = FK + copie éditable (§ 2.5). ✅
|
||||
3. **Pas de cloisonnement par site** (§ 2.3). ✅
|
||||
4. **Infra upload réutilisable `Shared`** (§ 2.7). ✅
|
||||
5. **Décharge obligatoire côté serveur** (RG-4.02) — si `certificationType=AUTRE` ⇒ `dischargeDocument` requis (422 sinon). ✅
|
||||
6. **Certification QUALIMAT** = 5e valeur de l'enum `certification_type`, en **lecture seule** (vient du référentiel), libellé affiché « QUALIMAT ». ✅
|
||||
7. **Affrètement** (RG-4.03) — indexation + benne/fond mouvant + volume **obligatoires server-side** si « Affréter » coché (fidèle au docx). ✅
|
||||
8. **Cas LIOT** (RG-4.01) — nom = `LIOT` ⇒ champ `liotPlates` seul affiché, autres champs masqués ; `certificationType` **non requis** en cas LIOT (nullable), obligatoire sinon. ✅
|
||||
9. **Unicité = nom seul** (§ 2.6). ✅
|
||||
|
||||
### ⚠️ Points purement techniques (pas de décision métier — défaut posé)
|
||||
|
||||
1. **Type de PK** : `BIGINT` (cohérence module Transport) — modifiable en `INT` si homogénéité globale souhaitée (§ 2.2).
|
||||
2. **Section sidebar** : « Transport » dédiée vs « Logistique » (route `/carriers` retenue). Cosmétique.
|
||||
@@ -0,0 +1,354 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M4
|
||||
nom: "Répertoire transporteurs"
|
||||
ecran: repertoire-transporteurs
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0.1
|
||||
date_redaction: 2026-06-15
|
||||
# Historique :
|
||||
# V0.1 (2026-06-15) — Restitution Markdown du docx « M4-repertoire-transporteurs-V0 »
|
||||
# (validé 27/05/2026) + maquette Figma (node 1132-45376). Précisions techniques (back)
|
||||
# dans spec-back.md. Réutilise le pattern et les composants M1/M2/M3.
|
||||
|
||||
# === LIENS ===
|
||||
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-45376&p=f&m=dev"
|
||||
regles_metier: [RG-4.01, RG-4.02, RG-4.03, RG-4.04, RG-4.05, RG-4.06, RG-4.07, RG-4.08, RG-4.09, RG-4.10, RG-4.11]
|
||||
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||
lien_spec_back: ./spec-back.md
|
||||
|
||||
# === VALIDATION CLIENT ===
|
||||
client_validation_1:
|
||||
statut: validee
|
||||
date: 2026-05-27
|
||||
version: V0
|
||||
valide_par: "Matthieu (CP MALIO)"
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_project_id: 6
|
||||
lesstime_taskgroup_id: 31 # M4 — Répertoire transporteurs (tickets ERP-153 → ERP-171)
|
||||
statut_global: pret_a_dev
|
||||
---
|
||||
|
||||
# Module 4 — Répertoire transporteurs (V0.1 front)
|
||||
|
||||
> **Origine** : spec fonctionnelle `M4-repertoire-transporteurs-V0` (validée le 27/05/2026) + maquette Figma. Restitution Markdown pour intégration au workflow MALIO. Toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M4 réutilise le pattern et les composants posés aux [M1 clients](../M1-clients/spec-front.md), [M2 fournisseurs](../M2-suppliers/spec-front.md) et [M3 prestataires](../M3-prestataires/spec-front.md).
|
||||
|
||||
> **Socle déjà en place** : le module back `Transport` existe (ERP-150) et porte deux référentiels **synchronisés par commandes console** : transporteurs **QUALIMAT** (`qualimat_carrier`, ERP-39) et codes **IDTF** (`idtf_product`, ERP-149). Le M4 ajoute le **répertoire éditable** (`Carrier`) **par-dessus** ces référentiels — la saisie assistée du nom interroge le référentiel QUALIMAT (RG-4.01). L'IDTF n'est **pas** utilisé par ces écrans.
|
||||
|
||||
> **Décisions Matthieu (15/06/2026)** : (1) lien QUALIMAT = FK + **copie éditable** des champs (nom / certification / adresse) ; (2) **pas de cloisonnement par site** (référentiel global) ; (3) le champ « Décharge » s'appuie sur une **infra d'upload réutilisable** (`Shared`), car d'autres uploads suivront. Détails : [`spec-back.md § 2.5 / § 2.3 / § 2.7`](./spec-back.md).
|
||||
|
||||
## But
|
||||
|
||||
Lister tous les transporteurs de l'organisation et accéder rapidement à leurs fiches : consultation, création, modification, archivage. Le nom est **relié à QUALIMAT** (saisie assistée) ; les transporteurs hors QUALIMAT (GMP+, OVOCOM, compte-propre, LIOT, autre) sont saisis manuellement.
|
||||
|
||||
## Accès
|
||||
|
||||
- **Depuis** : menu principal → section **Transport** (route `/carriers`). *(Section « Transport » dédiée ou rattachement à une section « Logistique » — à confirmer, cf. [`spec-back.md § 5.3`](./spec-back.md).)*
|
||||
- **Rôles autorisés** (tableau « Rôles & permissions » du docx) :
|
||||
|
||||
| Rôle | Consultation | Ajout / Modification | Archive |
|
||||
|---|---|---|---|
|
||||
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
|
||||
| **Bureau** | ✅ Tout | ✅ Tout | ❌ |
|
||||
| **Compta** | ❌ | ❌ | ❌ |
|
||||
| **Commerciale** | ✅ Tout | ❌ | ❌ |
|
||||
| **Usine** | ❌ | ❌ | ❌ |
|
||||
|
||||
> **Notes** :
|
||||
> - RBAC transposée sur `transport.carriers.*` (cf. [`spec-back.md § 5`](./spec-back.md)). **Commerciale** = consultation seule (pas de « + Ajouter » ni « Modifier »). **Compta** et **Usine** n'ont **aucun** accès au module (item sidebar masqué).
|
||||
> - **Pas de cloisonnement par site** (≠ M3) : tout rôle autorisé voit tous les transporteurs.
|
||||
|
||||
## Navigation
|
||||
|
||||
Page d'entrée du module **Transport** (route `/carriers`). Titre : « **Répertoire transporteurs** ».
|
||||
|
||||
- Affichage principal : un **datatable** listant tous les transporteurs **actifs** (les archivés sont masqués par défaut — filtre dédié).
|
||||
- **Clic sur une ligne** → écran **Consultation transporteur** (page dédiée).
|
||||
- **Bouton « + Ajouter »** (haut droite, si `manage`) → écran **Ajouter un transporteur**.
|
||||
- **Bouton « Filtrer »** (haut droite) → panneau de filtres.
|
||||
- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des transporteurs **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md).
|
||||
|
||||
### Panneau de filtres (bouton « Filtrer »)
|
||||
|
||||
Réutilise le pattern M1/M2/M3. Filtres branchés sur les query params de `GET /api/carriers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) :
|
||||
|
||||
| Filtre | Composant | Query param back |
|
||||
|---|---|---|
|
||||
| **Recherche** (nom) | `<MalioInputText>` | `?search=` |
|
||||
| **Certification** | `<MalioSelectCheckbox>` (QUALIMAT / GMP+ / OVOCOM / Compte-propre / Autre) | `?certificationType=` |
|
||||
| **Inclure les archivés** | `<MalioCheckbox>` | `?includeArchived=true` |
|
||||
|
||||
- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**).
|
||||
- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6).
|
||||
|
||||
## Datatable du Répertoire
|
||||
|
||||
Composant : `<MalioDataTable>` branché sur `usePaginatedList<Carrier>({ url: '/carriers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes :
|
||||
|
||||
| Colonne | Source | Tri |
|
||||
|---|---|---|
|
||||
| **Nom** | `carrier.name` | ASC par défaut |
|
||||
| **Certification** | `carrier.certificationType` (libellé i18n) | Non |
|
||||
| **Date de validité** | `carrier.qualimatCarrier.validityDate` (format `JJ-MM-AAAA`) — **fond rouge si < aujourd'hui** (RG-4.04) | Non |
|
||||
| **Dernière activité** | `carrier.updatedAt` (format `JJ-MM-AAAA`) | Oui |
|
||||
|
||||
> **Clic sur une ligne** → écran Consultation. **Pagination** : standard Starseed 10 / 25 / 50 (défaut 10). Tri serveur `name ASC` par défaut.
|
||||
|
||||
## Écran « Ajouter un transporteur »
|
||||
|
||||
Création par **onglets successifs avec validation incrémentale** : pour passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant** ; les champs validés passent en lecture seule. **L'onglet Adresses n'est accessible qu'une fois le formulaire principal validé.** Cf. [`spec-back.md § 2.9`](./spec-back.md) (PATCH partiels par groupe de sérialisation).
|
||||
|
||||
**Accès** : bouton « + Ajouter » du Répertoire. **Rôles** : Admin, Bureau.
|
||||
|
||||
**Barre d'onglets** : `Qualimat` · `Adresses` · `Contacts` · `Prix`.
|
||||
|
||||
### Formulaire principal (pré-onglets)
|
||||
|
||||
1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/carriers`, puis bascule sur l'onglet Qualimat/Adresses ; les champs passent en readonly.
|
||||
|
||||
| Champ | Type composant | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom** (saisie assistée reliée à QUALIMAT) | `<MalioInputText>` (autocomplete) | Oui | RG-4.01 ; RG-4.13 (UPPERCASE serveur) ; RG-4.12 (unicité) |
|
||||
| **Liste certification transport** | `<MalioSelect>` (GMP+ / OVOCOM / Compte-propre / Autre) | Oui | RG-4.02 ; auto = `QUALIMAT` (lecture seule) si transporteur QUALIMAT sélectionné |
|
||||
| **Affréter** | `<MalioCheckbox>` | Non | RG-4.03 |
|
||||
| **Indexation %** | `<MalioInputNumber>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
|
||||
| **Benne / Fond mouvant** | `<MalioRadioButton>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
|
||||
| **Volume m³** | `<MalioInputNumber>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
|
||||
| **Décharge** | `<MalioInputUpload>` *(cf. note)* | Conditionnel (**obligatoire si AUTRE**) | RG-4.02 — visible **et obligatoire** si certification = `AUTRE`. Upload via infra Shared ([`spec-back.md § 2.7`](./spec-back.md)) |
|
||||
| **Liste immatriculation LIOT** | `<MalioInputText>` (ou TextArea) | Cas LIOT | RG-4.01 — visible **uniquement** si nom = `LIOT` ; les autres champs disparaissent. Immatriculations séparées par `;` |
|
||||
|
||||
> **Comportement RG-4.01 (saisie assistée)** : à la saisie du nom, recherche dans le référentiel QUALIMAT via `GET /api/qualimat_carriers?search=`. Sélection d'un résultat → **modal de confirmation** « Êtes-vous sûr de vouloir intégrer ce transporteur ? ». Si confirmé : le **Nom** et la **certification** (= `QUALIMAT`, lecture seule) se remplissent automatiquement, **ainsi que l'onglet Adresse** (copie pays/CP/ville/voie depuis le référentiel). La FK QUALIMAT est conservée (traçabilité + date de validité RG-4.04).
|
||||
> - **Cas transporteur non trouvé** (pas QUALIMAT) : l'utilisateur choisit une autre certification (RG-4.02) → affichage des champs associés.
|
||||
> - **Cas LIOT** : si le nom saisi est exactement `LIOT`, seul le champ « Liste immatriculation LIOT » s'affiche, les autres champs sont masqués.
|
||||
|
||||
> **Note `<MalioInputUpload>`** : si le composant ne couvre pas le drag & drop / type fichier requis, exception autorisée documentée (`// TODO migrer quand Malio couvre`) — cf. exceptions @.claude/rules/frontend.md.
|
||||
|
||||
**Action** : « Valider » (`<MalioButton>`) → POST `/api/carriers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Qualimat » / « Adresses ».
|
||||
|
||||
### Onglet « Qualimat »
|
||||
|
||||
Sélectionner un transporteur de la liste QUALIMAT afin de mettre à jour les informations du transporteur (saisie assistée — voir RG-4.01).
|
||||
|
||||
**Colonnes du tableau de sélection** :
|
||||
|
||||
| Colonne | Règle |
|
||||
|---|---|
|
||||
| **Sélection** (bouton / clic ligne) | RG-4.03 *(docx)* — clic → modal « Êtes-vous sûr de vouloir intégrer ce transporteur ? » → remplit Nom + certification + onglet adresse |
|
||||
| **Nom** | — |
|
||||
| **Adresse** | — |
|
||||
| **Date de validité** | RG-4.04 — **fond rouge si < date du jour** |
|
||||
|
||||
> Cet onglet alimente le formulaire principal et l'onglet Adresse par copie (RG-4.01 / RG-4.05). Source : `GET /api/qualimat_carriers?search=` (lecture seule, lignes actives uniquement).
|
||||
|
||||
### Onglet « Adresses »
|
||||
|
||||
Saisir l'adresse du transporteur (un bloc par adresse).
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Pays** | `<MalioSelect>` (préremplie « France ») | Conditionnel | RG-4.05 |
|
||||
| **Code postal** | `<MalioInputText>` (saisie assistée) | Conditionnel | RG-4.06, RG-4.05 — déclenche autocomplete ville (BAN) |
|
||||
| **Ville** | `<MalioSelect>` (saisie assistée) | Conditionnel | RG-4.06, RG-4.05 — alimentée par api-adresse.data.gouv.fr |
|
||||
| **Adresse** | `<MalioInputText>` (saisie assistée) | Conditionnel | RG-4.05 |
|
||||
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
|
||||
|
||||
> **RG-4.05** : les champs sont **déjà remplis** si le transporteur est QUALIMAT (copie). Si « Affréter » est coché, l'adresse devient **obligatoire** (Pays, Code postal, Ville, Adresse).
|
||||
> **RG-4.06** : la ville est préremplie automatiquement à partir du code postal via l'API BAN (`useAddressAutocomplete()`, réutilisé M1/M2/M3). Si plusieurs villes → choix dans le select. L'adresse est une saisie assistée basée sur le CP et la ville.
|
||||
> **RG-4.07** : le bouton « Valider » **n'apparaît pas** pour un transporteur QUALIMAT (adresse remplie automatiquement).
|
||||
|
||||
**Actions** : « Valider » → PATCH `/api/carriers/{id}/addresses` (sauf QUALIMAT, RG-4.07).
|
||||
|
||||
### Onglet « Contacts »
|
||||
|
||||
Saisir un ou plusieurs contacts associés au transporteur.
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom** | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (Capitalize) |
|
||||
| **Prénom** | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (Capitalize) |
|
||||
| **Fonction** | `<MalioInputText>` | Non | RG-4.08 |
|
||||
| **Téléphone** (x1, +1 possible, **max 2**) | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (format) |
|
||||
| **Email** | `<MalioInputText>` type email | Non | RG-4.08 + RG-4.13 (lowercase) |
|
||||
|
||||
**RG-4.08** : un bloc Contact est valide dès qu'au moins 1 champ est rempli. Impossible d'ajouter un nouveau bloc tant que le précédent n'est pas valide.
|
||||
|
||||
**Actions** :
|
||||
- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a aucun champ rempli** (RG-4.08).
|
||||
- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc.
|
||||
- « Valider » → PATCH `/api/carriers/{id}/contacts`.
|
||||
|
||||
### Onglet « Prix »
|
||||
|
||||
Saisir un suivi de prix du transporteur (un bloc par prix). Tous les champs sont masqués par défaut sauf le radio « Client / Fournisseur » (RG-4.09).
|
||||
|
||||
**Bloc Prix** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Client / Fournisseur** | `<MalioRadioButton>` | Oui | RG-4.09 |
|
||||
| **Client** | `<MalioSelect>` (liste des clients) | Conditionnel | RG-4.10 — si Client |
|
||||
| **Adresse de livraison** | `<MalioSelect>` (adresses du client sélectionné) | Conditionnel | RG-4.10 — si Client |
|
||||
| **Adresse de départ** | `<MalioSelect>` (86 / 17 / 82) | Conditionnel | RG-4.10 — si Client ; = un des 3 sites |
|
||||
| **Fournisseur** | `<MalioSelect>` (liste des fournisseurs) | Conditionnel | RG-4.11 — si Fournisseur |
|
||||
| **Adresse d'approvisionnement** | `<MalioSelect>` (adresses du fournisseur) | Conditionnel | RG-4.11 — si Fournisseur |
|
||||
| **Adresse de livraison** | `<MalioSelect>` (86 / 17 / 82) | Conditionnel | RG-4.11 — si Fournisseur ; = un des 3 sites |
|
||||
| **Benne / Fond mouvant (FM)** | `<MalioRadioButton>` | Oui | — |
|
||||
| **Forfait / Tonne** | `<MalioRadioButton>` | Oui | — |
|
||||
| **Prix** | `<MalioInputAmount>` (monnaie) | Oui | — |
|
||||
| **État du prix** | `<MalioSelect>` (En cours / Validé / Non validé) | Oui | — |
|
||||
|
||||
> **RG-4.10** : si **Client** sélectionné → champs liés au client affichés et obligatoires ; champs fournisseur masqués et non obligatoires.
|
||||
> **RG-4.11** : si **Fournisseur** sélectionné → champs liés au fournisseur affichés et obligatoires ; champs client masqués et non obligatoires.
|
||||
> **Adresse de départ / livraison « 86 / 17 / 82 »** = les 3 `Site` fixes (cf. switcher de site Châtellerault / Saint-Jean / Pommevic en haut de l'app). La sélection stocke un **ID de Site** ([`spec-back.md § 3.2`](./spec-back.md)).
|
||||
|
||||
**Actions** :
|
||||
- « + Nouveau prix » : ajoute un bloc. Bloqué tant que le précédent n'est pas valide.
|
||||
- « Supprimer » (icône) : modal de confirmation puis suppression.
|
||||
- « Valider » → PATCH `/api/carriers/{id}/prices`.
|
||||
|
||||
## Écran « Consultation d'un transporteur »
|
||||
|
||||
Consulter en **lecture seule** la fiche complète. Affiche en haut du bloc les infos principales du transporteur (comme l'écran d'ajout) ainsi que les onglets Adresses, Contacts, Prix. **Tous les champs sont en lecture seule.**
|
||||
|
||||
**Accès** : clic sur une ligne du Répertoire. La page s'ouvre par défaut sur l'onglet **Adresses**. Icône « flèche » à gauche pour revenir au répertoire. Deux boutons à droite :
|
||||
- **« Modifier »** (visible si `transport.carriers.manage` → Admin, Bureau).
|
||||
- **« Archiver »** (visible **uniquement Admin** via `transport.carriers.archive`) → modal de confirmation, puis PATCH `/api/carriers/{id}` `{ "isArchived": true }`.
|
||||
|
||||
> Un transporteur archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé.
|
||||
|
||||
### Onglet Adresses (consultation)
|
||||
|
||||
Un bloc par adresse du transporteur. Chaque bloc, 5 champs en lecture seule : Pays / Code postal / Ville / Adresse / Adresse complémentaire.
|
||||
|
||||
### Onglet Contacts (consultation)
|
||||
|
||||
Un bloc par contact. 5 champs en lecture seule : Nom / Prénom / Fonction / Téléphone (x1 ou x2) / Email.
|
||||
|
||||
### Onglet Prix (consultation)
|
||||
|
||||
Un tableau regroupant les prix par type (**Fond Mouvant / Benne**) :
|
||||
|
||||
| Colonne | Description |
|
||||
|---|---|
|
||||
| **Colonne de regroupement** | « Fond Mouvant » / « Benne » |
|
||||
| **Transporteurs** | Nom du transporteur |
|
||||
| **Adresse APRO ou Adresse Sites** | Si prix « Client » → Adresse APRO sinon Adresse Sites |
|
||||
| **Adresse livraisons** | — |
|
||||
| **Forfait €** | Prix |
|
||||
| **Tonne €** | Prix |
|
||||
| **Indexation** | Pourcentage d'indexation (vide si non rempli) |
|
||||
| **État du prix** | Validé / Non Validé / En cours |
|
||||
|
||||
**Action** : « Exporter » → exporte le tableau au **format Excel** (`GET /api/carriers/{id}/prices/export.xlsx`).
|
||||
|
||||
## Écran « Modification d'un transporteur »
|
||||
|
||||
Modifier les informations d'un transporteur existant. **Identique à l'écran « Ajouter un transporteur »** — mêmes formulaires, mêmes règles métier (RG-4.01 à RG-4.11) — sauf :
|
||||
- Les champs sont **pré-remplis** avec les valeurs actuelles.
|
||||
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
|
||||
- **Accès** : depuis l'écran Consultation, bouton « Modifier » (Admin, Bureau).
|
||||
|
||||
## Composants UI à utiliser (`@malio/layer-ui`)
|
||||
|
||||
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
|
||||
- **Input texte** : `<MalioInputText>`
|
||||
- **Input nombre / montant** : `<MalioInputNumber>` (indexation, volume), `<MalioInputAmount>` (prix)
|
||||
- **Select simple** : `<MalioSelect>` (certification, pays, ville, client, fournisseur, adresses, sites, état du prix)
|
||||
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (filtres certification)
|
||||
- **Radio** : `<MalioRadioButton>` (Benne/Fond mouvant, Forfait/Tonne, Client/Fournisseur)
|
||||
- **Checkbox** : `<MalioCheckbox>` (Affréter, inclure archivés)
|
||||
- **Upload** : `<MalioInputUpload>` (Décharge — exception documentée si type non couvert)
|
||||
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
|
||||
- **Toasts** : standards via `useApi()`
|
||||
- **Validation par champ** : `useFormErrors` (mapping 422 inline — règle frontend obligatoire)
|
||||
|
||||
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
|
||||
- Modal de confirmation : wrapper partagé dans `frontend/shared/` (réutiliser celui du M1/M2/M3).
|
||||
- `<MalioInputUpload>` si le type fichier / drag & drop n'est pas couvert.
|
||||
|
||||
## Composables & appels API
|
||||
|
||||
- `usePaginatedList<Carrier>({ url: '/carriers' })` — liste paginée (obligatoire). Consomme `name`, `certificationType`, `qualimatCarrier.validityDate` (RG-4.04), `updatedAt` (cf. [`spec-back.md § 2.11 / § 4.0`](./spec-back.md)).
|
||||
- `useCarrier(id)` — charge le détail via `GET /api/carriers/{id}`, qui **embarque** `addresses`, `contacts`, `prices` (avec `client`/`supplier`/sites imbriqués) + `qualimatCarrier`. Écrans Consultation et Modification peuplés depuis cette seule réponse. **DoD avant intégration** : vérifier le JSON réel (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)).
|
||||
- `useCarrierForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useSupplierForm()`/`useProviderForm()` + gestion des **champs conditionnels** (Affréter, AUTRE→Décharge, cas LIOT).
|
||||
- `useQualimatSearch()` — saisie assistée du nom : `GET /api/qualimat_carriers?search=`, modal de confirmation, copie des champs + FK (RG-4.01).
|
||||
- `useAddressAutocomplete()` — **réutilisé** du M1/M2/M3 (BAN), pas de réécriture (RG-4.06).
|
||||
- `useUpload()` (NOUVEAU, infra Shared) — POST multipart `/api/uploaded_documents` → renvoie l'IRI à poser sur `carrier.dischargeDocument` (RG-4.02).
|
||||
- `usePermissions()` — masque l'item sidebar et les boutons selon les permissions.
|
||||
- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4).
|
||||
- Filter `formatPhoneFR()` — **réutilisé** pour l'affichage `XX XX XX XX XX`.
|
||||
|
||||
## Règles de formatage et normalisation
|
||||
|
||||
Le serveur normalise systématiquement (RG-4.13 — cf. [`spec-back.md`](./spec-back.md)) :
|
||||
|
||||
| Champ | Normalisation serveur | Affichage front |
|
||||
|---|---|---|
|
||||
| Nom transporteur (`name`) | UPPERCASE intégral | UPPERCASE |
|
||||
| Nom + Prénom contact | Capitalize | identique |
|
||||
| Téléphones (`CarrierContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` (filter Vue) |
|
||||
| Email | lowercase intégral | identique |
|
||||
| Immatriculations LIOT | `;`-split, trim, UPPER | listées |
|
||||
|
||||
> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur normalisée que l'UI affiche.
|
||||
|
||||
## API adresse postale
|
||||
|
||||
Code postal + Ville + Adresse branchés sur **api-adresse.data.gouv.fr** (BAN) via le composable `useAddressAutocomplete()` **déjà créé au M1/M2/M3** (réutilisé tel quel) :
|
||||
- À la saisie du CP (5 chiffres) : `GET https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville (RG-4.06 : si plusieurs villes, choix dans le select).
|
||||
- À la saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions.
|
||||
- Cas dégradé (timeout / offline) : Ville en `<MalioInputText>` libre + toast d'avertissement.
|
||||
|
||||
## Différences notables avec les modules précédents
|
||||
|
||||
| Zone | M2/M3 | M4 transporteurs |
|
||||
|---|---|---|
|
||||
| Source du nom | saisie libre | **saisie assistée reliée à QUALIMAT** (référentiel synchronisé) |
|
||||
| Onglet Comptabilité / RIB | présent (M2/M3) | **Absent** |
|
||||
| Cloisonnement par site | M3 : oui | **Non** (référentiel global) |
|
||||
| Champs conditionnels formulaire principal | peu | **Nombreux** (Affréter, AUTRE→Décharge, cas LIOT) |
|
||||
| Onglet Prix | absent | **Présent** (Client/Fournisseur, sites départ/livraison) |
|
||||
| Upload de fichier | aucun | **Décharge** (infra upload Shared, réutilisable) |
|
||||
| Module | Commercial / Technique | **Transport** (existant, ERP-150) |
|
||||
|
||||
## Points résolus côté back
|
||||
|
||||
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|
||||
|---|---|---|
|
||||
| 1 | Lien QUALIMAT | FK `qualimatCarrier` + **copie éditable** des champs (§ 2.5) |
|
||||
| 2 | Cas LIOT | Champ `liotPlates` (`;`-séparé), autres champs masqués (RG-4.01) |
|
||||
| 3 | Certification QUALIMAT | Valeur `QUALIMAT` lecture seule si lié (§ 2.5) |
|
||||
| 4 | Décharge (upload) | Infra upload générique `Shared` réutilisable (§ 2.7) |
|
||||
| 5 | Onglet Prix — branches | M2M absentes : FK Client/Supplier + adresses + sites (RG-4.10/4.11, § 3.2) |
|
||||
| 6 | Adresse de départ/livraison 86/17/82 | = les 3 `Site` fixes (FK Site) |
|
||||
| 7 | Workflow par onglet | Sauvegarde incrémentale (POST principal + PATCH partiels) — pas d'état « draft » |
|
||||
| 8 | Archive vs delete | Flag `is_archived` séparé ; archivage Admin seul ; soft delete = HP |
|
||||
| 9 | Unicité métier | Nom seul (§ 2.6) |
|
||||
| 10 | Référentiel QUALIMAT | Endpoint lecture seule `GET /api/qualimat_carriers?search=` (§ 4.7) |
|
||||
| 11 | Format export | XLSX (répertoire + onglet Prix regroupé Benne/FM) |
|
||||
| 12 | RBAC | `transport.carriers.view/manage/archive` ; Compta + Usine sans accès (§ 5.2) |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tickets Lesstime
|
||||
|
||||
**TaskGroup Lesstime** : à créer — `M4 — Répertoire transporteurs` (projet `ERP / Starseed`, projectId=6). Découpe détaillée (back en tête) → [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper).
|
||||
|
||||
| Ordre | Sujet | Tag |
|
||||
|---|---|---|
|
||||
| 0 | Permissions `transport.carriers.*` + sidebar + 3 sources RBAC | Backend |
|
||||
| 1 | Infra upload générique `Shared` (uploaded_document + FileUploader + endpoint) | Backend |
|
||||
| 2 | Migration BDD M4 (carrier + sous-collections + index + COMMENT) | Backend |
|
||||
| 3 | Entité `QualimatCarrier` (lecture seule) + endpoint recherche | Backend |
|
||||
| 4 | Entités + Repositories Carrier* | Backend |
|
||||
| 5 | CarrierProvider + CarrierProcessor (champs conditionnels, archive, LIOT) | Backend |
|
||||
| 6 | Sous-ressources Adresses / Contacts / Prix (RG-4.10/4.11) | Backend |
|
||||
| 7 | Export XLSX (répertoire + onglet Prix) | Backend |
|
||||
| 8 | Tests PHPUnit RG-4.01→4.14 + capture contrat JSON | Backend |
|
||||
| 9 | Page Répertoire (`/carriers`) + usePaginatedList | Frontend |
|
||||
| 10 | Page Ajouter + formulaire principal + saisie assistée QUALIMAT | Frontend |
|
||||
| 11 | Onglets Adresses (BAN) / Contacts / Prix | Frontend |
|
||||
| 12 | Pages Consultation + Modification | Frontend |
|
||||
| 13 | i18n + libellés audit + upload front (useUpload) | Frontend |
|
||||
@@ -0,0 +1,307 @@
|
||||
# M4 — Répertoire transporteurs · Découpe en tickets Lesstime
|
||||
|
||||
> **Statut** : ✅ **poussé dans Lesstime** — TaskGroup **#31 « M4 — Répertoire transporteurs »** (projet STARSEED), 19 tickets **ERP-153 → ERP-171** au statut **Prêt à dev**.
|
||||
> **Assignation** : tickets **Backend (1.1→1.11, ERP-153→163) → Matthieu** · tickets **Frontend (1.12→1.19, ERP-164→171) → Tristan**.
|
||||
>
|
||||
> | Pos | Ticket | Réf |
|
||||
> |---|---|---|
|
||||
> | 1.1 | Permissions transport.carriers.* + sidebar | ERP-153 |
|
||||
> | 1.2 | Infra upload générique Shared | ERP-154 |
|
||||
> | 1.3 | Migration BDD M4 | ERP-155 |
|
||||
> | 1.4 | QualimatCarrier + endpoint recherche | ERP-156 |
|
||||
> | 1.5 | Entités Carrier* + ApiResource + Provider | ERP-157 |
|
||||
> | 1.6 | CarrierProcessor (RG-4.01/02/03 + LIOT) | ERP-158 |
|
||||
> | 1.7 | Sous-ressource Adresses | ERP-159 |
|
||||
> | 1.8 | Sous-ressource Contacts | ERP-160 |
|
||||
> | 1.9 | Sous-ressource Prix + branches | ERP-161 |
|
||||
> | 1.10 | Export XLSX | ERP-162 |
|
||||
> | 1.11 | Tests PHPUnit + contrat JSON | ERP-163 |
|
||||
> | 1.12 | Page Répertoire /carriers | ERP-164 |
|
||||
> | 1.13 | Page Ajouter (layout + formulaire) | ERP-165 |
|
||||
> | 1.14 | Saisie assistée QUALIMAT + conditionnels | ERP-166 |
|
||||
> | 1.15 | Onglet Adresses (BAN) | ERP-167 |
|
||||
> | 1.16 | Onglet Contacts | ERP-168 |
|
||||
> | 1.17 | Onglet Prix | ERP-169 |
|
||||
> | 1.18 | Consultation + Modification | ERP-170 |
|
||||
> | 1.19 | Upload front + i18n + audit | ERP-171 |
|
||||
> **Specs sources** : [`spec-back.md`](./spec-back.md) · [`spec-front.md`](./spec-front.md) — validées (docx V0 du 27/05/2026).
|
||||
> **Maquette Figma** : node `1132-45376` ([lien](https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-45376&p=f&m=dev)).
|
||||
|
||||
## ⚠️ Dépendance amont (socle Tristan — en cours de merge)
|
||||
|
||||
Le M4 s'appuie sur le module `Transport` et le référentiel QUALIMAT, livrés par les PR de Tristan **en cours de merge** dans `develop` :
|
||||
|
||||
- **ERP-150** (PR #97) — module `Transport` (`TransportModule`, layer front, `config/modules.php`). **Requis** par tout le M4.
|
||||
- **ERP-39** (PR #99) — sync QUALIMAT (`qualimat_carrier` + commande `app:qualimat:sync`). **Requis** par la saisie assistée (ticket 1.4).
|
||||
- **ERP-149** (PR #101) — sync IDTF (`idtf_product`). **NON requis** par le M4 (référentiel autonome, hors écrans transporteurs).
|
||||
|
||||
> Les 3 PR sont **empilées** (`develop → ERP-150 → ERP-39 → ERP-149`). Démarrer le M4 une fois **ERP-150 + ERP-39 dans `develop`** (DoR des tickets 1.1 et 1.4). Brancher le M4 sur `develop` post-merge.
|
||||
|
||||
## Vue d'ensemble (ordre d'exécution)
|
||||
|
||||
| # | Ticket | Tag | Effort | RG / dépend |
|
||||
|---|---|---|---|---|
|
||||
| 1.1 | Déclarer permissions `transport.carriers.*` + sidebar | Backend | S | DoR : ERP-150 mergé |
|
||||
| 1.2 | Créer l'infra d'upload générique `Shared` | Backend | M | § 2.7 |
|
||||
| 1.3 | Migrer le schéma BDD M4 (carrier + sous-tables) | Backend | M | § 3.2 |
|
||||
| 1.4 | Exposer `QualimatCarrier` (lecture seule) + endpoint recherche | Backend | S | RG-4.01 · DoR : ERP-39 mergé |
|
||||
| 1.5 | Créer entités `Carrier*` + repos + `ApiResource` + `CarrierProvider` | Backend | M | § 3.3 / 4.0 |
|
||||
| 1.6 | Implémenter `CarrierProcessor` (RG-4.01/4.02/4.03 + LIOT + normalisation + archive) | Backend | M | RG-4.01→4.03, 4.13, 4.14 |
|
||||
| 1.7 | Sous-ressource Adresses (`carrier_address`) | Backend | S | RG-4.05→4.07 |
|
||||
| 1.8 | Sous-ressource Contacts (`carrier_contact`) | Backend | S | RG-4.08 |
|
||||
| 1.9 | Sous-ressource Prix (`carrier_price`) + RG branches | Backend | M | RG-4.09→4.11 |
|
||||
| 1.10 | Export XLSX (répertoire + onglet Prix regroupé) | Backend | M | § 4.6 |
|
||||
| 1.11 | Tests PHPUnit RG-4.01→4.14 + capture contrat JSON (DoD) | Backend | M | § 4.0.bis / 8.1 |
|
||||
| 1.12 | Page Répertoire `/carriers` (datatable, filtres, export) | Frontend | M | RG-4.04 |
|
||||
| 1.13 | Page Ajouter `/carriers/new` (layout, onglets, formulaire principal POST) | Frontend | M | RG-4.12 |
|
||||
| 1.14 | Saisie assistée QUALIMAT + champs conditionnels (Affréter / AUTRE→Décharge / LIOT) | Frontend | M | RG-4.01→4.03 |
|
||||
| 1.15 | Onglet Adresses (autocomplete BAN) | Frontend | M | RG-4.05→4.07 |
|
||||
| 1.16 | Onglet Contacts | Frontend | S | RG-4.08 |
|
||||
| 1.17 | Onglet Prix (Client/Fournisseur, sites) | Frontend | M | RG-4.09→4.11 |
|
||||
| 1.18 | Pages Consultation + Modification | Frontend | M | — |
|
||||
| 1.19 | Upload front (`useUpload`) + i18n + libellés audit | Frontend | S | § 2.8 |
|
||||
|
||||
**Total** : 19 tickets · ~11 back / 8 front · mini-MR de 1 à 4h.
|
||||
|
||||
---
|
||||
|
||||
## Tickets — détail
|
||||
|
||||
### 1.1 — Déclarer permissions `transport.carriers.*` + sidebar
|
||||
**Position** : 1.1 • Suit : — • Précède : Migrer le schéma BDD M4
|
||||
**Tag** : Backend • **Effort** : S
|
||||
**Contexte** : `TransportModule::permissions()` renvoie aujourd'hui `[]`. Ce ticket pose le socle RBAC du module et son entrée de menu, prérequis de toute opération sécurisée.
|
||||
**Spec liée** : [`spec-back.md § 5`](./spec-back.md) · [`spec-front.md § Accès`](./spec-front.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] `TransportModule::permissions()` déclare `transport.carriers.view`, `transport.carriers.manage`, `transport.carriers.archive` ; `app:sync-permissions` les enregistre.
|
||||
- [ ] **Matrice § 5.2** : Admin (view+manage+archive), Bureau (view+manage), Commerciale (view), Compta + Usine (aucune).
|
||||
- [ ] **3 sources RBAC alignées dans le même commit** (règle ABSOLUE n°8) : `config/sidebar.php` (section Transport + item `/carriers` + permission), `personas.ts`, `SeedE2ECommand.php`.
|
||||
- [ ] Item sidebar masqué pour Compta/Usine ; visible Admin/Bureau/Commerciale.
|
||||
**Tests à prévoir** : permissions sync OK ; personas e2e cohérents (pas de drift).
|
||||
**Tips** : DoR — ERP-150 mergé (module Transport présent). Section sidebar « Transport » (ou « Logistique » — à trancher, cosmétique).
|
||||
|
||||
### 1.2 — Créer l'infra d'upload générique `Shared`
|
||||
**Position** : 1.2 • Suit : permissions • Précède : Migration M4
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Contexte** : la « Décharge » (RG-4.02) est le 1er d'une série d'uploads à venir. On pose une infra réutilisable, pas un upload ad hoc.
|
||||
**Spec liée** : [`spec-back.md § 2.7`](./spec-back.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Table `uploaded_document` (`original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum`, `created_at`, `created_by`) + COMMENT ON COLUMN.
|
||||
- [ ] Service `Shared\Infrastructure\Upload\FileUploader` : validation MIME **server-side via `$file->getMimeType()`** (jamais `getClientMimeType()`), bornage taille, checksum sha256, écriture disque (`var/uploads/{yyyy}/{mm}/`).
|
||||
- [ ] Endpoint `POST /api/uploaded_documents` (multipart) → renvoie l'IRI ; whitelist MIME explicite (PDF + images) ; hors whitelist → 422.
|
||||
**Tests à prévoir** : PHPUnit — MIME hors whitelist → 422 ; MIME valide → IRI + ligne persistée ; checksum calculé.
|
||||
**Tips** : générique et réutilisable (autres modules la consommeront). Antivirus / S3 / purge = HP (§ 9).
|
||||
|
||||
### 1.3 — Migrer le schéma BDD M4 (carrier + sous-tables)
|
||||
**Position** : 1.3 • Suit : infra upload • Précède : QualimatCarrier
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Contexte** : créer le schéma du répertoire (entité éditable distincte du référentiel `qualimat_carrier`).
|
||||
**Spec liée** : [`spec-back.md § 3.2`](./spec-back.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Migration namespace racine `DoctrineMigrations`, **postérieure** à `Version20260612160000`.
|
||||
- [ ] Tables `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` + FK (`qualimat_carrier`, `uploaded_document`, `client`, `client_address`, `supplier`, `supplier_address`, `site`, `user`).
|
||||
- [ ] `certification_type` **nullable** (null seulement en cas LIOT) + CHECK enum ; CHECK `container_type`, `direction`, `pricing_unit`, `price_state`, branches Prix client/fournisseur.
|
||||
- [ ] Index partiel `uq_carrier_name_active` (LOWER(name), WHERE non archivé & non supprimé).
|
||||
- [ ] **`COMMENT ON COLUMN` sur TOUTES les colonnes** (règle n°12) + helper Timestampable/Blamable. `ColumnsHaveSqlCommentTest` vert.
|
||||
- [ ] `make db-reset` passe ; schéma conforme.
|
||||
**Tests à prévoir** : `make db-reset` OK ; `ColumnsHaveSqlCommentTest` vert ; index partiel présent.
|
||||
**Tips** : PK `BIGINT` (cohérence module Transport) — à confirmer vs `INT`.
|
||||
|
||||
### 1.4 — Exposer `QualimatCarrier` (lecture seule) + endpoint recherche
|
||||
**Position** : 1.4 • Suit : migration • Précède : entités Carrier*
|
||||
**Tag** : Backend • **Effort** : S
|
||||
**Contexte** : la saisie assistée du nom (RG-4.01) a besoin d'un endpoint de recherche sur le référentiel QUALIMAT, aujourd'hui alimenté en console mais non exposé.
|
||||
**Spec liée** : [`spec-back.md § 4.7`](./spec-back.md) · RG-4.01
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Entité `QualimatCarrier` (lecture seule) mappée sur la table existante `qualimat_carrier` (aucune écriture exposée).
|
||||
- [ ] `GET /api/qualimat_carriers?search=` : fuzzy sur `name` (+ `siret`), **seulement `is_active = true`**, tri `name`, paginé (règle n°13).
|
||||
- [ ] **Security** `is_granted('transport.carriers.view')`. Champs exposés : `id, siret, name, address, postalCode, city, phone, department, status, validityDate, isActive`.
|
||||
**Tests à prévoir** : PHPUnit — recherche ne renvoie que les actifs ; pagination Hydra ; 403 sans permission.
|
||||
**Tips** : DoR — ERP-39 mergé. Ne pas toucher la commande de sync.
|
||||
|
||||
### 1.5 — Créer entités `Carrier*` + repos + `ApiResource` + `CarrierProvider`
|
||||
**Position** : 1.5 • Suit : QualimatCarrier • Précède : CarrierProcessor
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Contexte** : poser les entités, le contrat de sérialisation (groupes) et la lecture (liste + détail).
|
||||
**Spec liée** : [`spec-back.md § 3.3 / 3.4 / 4.0 / 4.1 / 4.2`](./spec-back.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Entités `Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice` (`#[Auditable]`, `TimestampableBlamableTrait`), repos Doctrine.
|
||||
- [ ] `ApiResource` Carrier : `GetCollection` + `Get` + `Post` + `Patch` avec `security` (§ 3.3) ; **pas de Delete**.
|
||||
- [ ] Groupes de sérialisation : `carrier:read`, `carrier:item:read`, `qualimat:read`, embed `client:read`/`client_address:read`/`supplier:read`/`supplier_address:read`/`site:read` au détail (3 maillons § 4.0 — ⚠ les adresses de l'onglet Prix sont des entités `ClientAddress`/`SupplierAddress` distinctes).
|
||||
- [ ] `CarrierProvider` paginé (`ApiPlatform\Doctrine\Orm\Paginator`) ; liste **sans cloisonnement site** (§ 2.3) ; anti-N+1 (§ 2.11).
|
||||
- [ ] Piège booléen `isArchived` : `#[SerializedName('isArchived')]` sur le getter.
|
||||
**Tests à prévoir** : liste exclut archivés par défaut ; `?includeArchived=true` ; enveloppe Hydra ; `isArchived` présent dans le JSON.
|
||||
**Tips** : miroir `Supplier`/`Provider`. Pas d'onglet Comptabilité (≠ M2/M3).
|
||||
|
||||
### 1.6 — Implémenter `CarrierProcessor`
|
||||
**Position** : 1.6 • Suit : entités • Précède : sous-ressource Adresses
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Contexte** : logique d'écriture du formulaire principal (POST/PATCH) : normalisation, champs conditionnels, archivage.
|
||||
**Spec liée** : [`spec-back.md § 4.3 / 4.4 / 7`](./spec-back.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] **RG-4.01** : POST avec `qualimatCarrier` → `certificationType=QUALIMAT` + FK persistée ; cas LIOT : `name='LIOT'` ⇒ `certificationType` non requis, `liotPlates` accepté.
|
||||
- [ ] **RG-4.02** : `certificationType='AUTRE'` sans `dischargeDocument` → **422** (`#[Assert\Callback]`).
|
||||
- [ ] **RG-4.03** : `isChartered=true` sans `indexationRate`/`containerType`/`volumeM3` → **422**.
|
||||
- [ ] **RG-4.13** : normalisation (`name` UPPER, contacts Capitalize, phones digits, email lower, `liotPlates`).
|
||||
- [ ] **RG-4.12** : doublon `name` (actifs) → **409**.
|
||||
- [ ] **RG-4.14** : PATCH `isArchived` exige `transport.carriers.archive` (Admin) ; mode strict (403 sinon).
|
||||
**Tests à prévoir** : PHPUnit sur chaque RG ci-dessus (cf. § 8.1).
|
||||
**Tips** : `CarrierFieldNormalizer` miroir `SupplierFieldNormalizer`.
|
||||
|
||||
### 1.7 — Sous-ressource Adresses (`carrier_address`)
|
||||
**Position** : 1.7 • Suit : CarrierProcessor • Précède : Contacts
|
||||
**Tag** : Backend • **Effort** : S
|
||||
**Spec liée** : [`spec-back.md § 4.5`](./spec-back.md) · RG-4.05→4.07
|
||||
**Critères d'acceptation** :
|
||||
- [ ] `POST /api/carriers/{id}/addresses`, `PATCH`/`DELETE /api/carrier_addresses/{id}` (security `manage`).
|
||||
- [ ] **RG-4.06** : `postalCode` matche `^[0-9]{4,5}$` (autocomplete ville = front).
|
||||
- [ ] **RG-4.05** : si affrété, adresse obligatoire (Pays/CP/Ville/Adresse) — validation conditionnelle.
|
||||
**Tests à prévoir** : PHPUnit — CP invalide → 422 ; adresse affrété incomplète → 422.
|
||||
**Tips** : RG-4.07 (bouton Valider masqué si QUALIMAT) = front, back accepte le PATCH.
|
||||
|
||||
### 1.8 — Sous-ressource Contacts (`carrier_contact`)
|
||||
**Position** : 1.8 • Suit : Adresses • Précède : Prix
|
||||
**Tag** : Backend • **Effort** : S
|
||||
**Spec liée** : [`spec-back.md § 4.5`](./spec-back.md) · RG-4.08
|
||||
**Critères d'acceptation** :
|
||||
- [ ] `POST /api/carriers/{id}/contacts`, `PATCH`/`DELETE /api/carrier_contacts/{id}` (security `manage`).
|
||||
- [ ] **RG-4.08** : bloc valide si ≥ 1 champ rempli (CHECK `chk_carrier_contact_filled` + Processor) ; **max 2 téléphones**.
|
||||
**Tests à prévoir** : PHPUnit — contact vide → 422 ; 1 champ → 200.
|
||||
**Tips** : miroir contacts M2/M3.
|
||||
|
||||
### 1.9 — Sous-ressource Prix (`carrier_price`) + RG branches
|
||||
**Position** : 1.9 • Suit : Contacts • Précède : Export
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Spec liée** : [`spec-back.md § 4.5 / 7`](./spec-back.md) · RG-4.09→4.11
|
||||
**Critères d'acceptation** :
|
||||
- [ ] `POST /api/carriers/{id}/prices`, `PATCH`/`DELETE /api/carrier_prices/{id}` (security `manage`).
|
||||
- [ ] **RG-4.10** (CLIENT) : `client`, `clientDeliveryAddress`, `departureSite` requis ; `clientDeliveryAddress` doit appartenir au `client` → sinon 422.
|
||||
- [ ] **RG-4.11** (FOURNISSEUR) : `supplier`, `supplierSupplyAddress`, `deliverySite` requis ; `supplierSupplyAddress` appartient au `supplier` → sinon 422.
|
||||
- [ ] Communs obligatoires : `containerType`, `pricingUnit`, `price`, `priceState` ; CHECK branches respectées.
|
||||
**Tests à prévoir** : PHPUnit — branche CLIENT/FOURNISSEUR incomplète → 422 ; adresse étrangère → 422.
|
||||
**Tips** : « Adresse départ/livraison 86/17/82 » = `Site` (FK) ; livraison client = `ClientAddress`, appro = `SupplierAddress` (relations ORM partagées).
|
||||
|
||||
### 1.10 — Export XLSX (répertoire + onglet Prix regroupé)
|
||||
**Position** : 1.10 • Suit : Prix • Précède : Tests PHPUnit
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Spec liée** : [`spec-back.md § 4.6`](./spec-back.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] `GET /api/carriers/export.xlsx` : transporteurs affichés (mêmes filtres) ; colonnes § 4.6.
|
||||
- [ ] `GET /api/carriers/{id}/prices/export.xlsx` : tableau Prix regroupé Benne / Fond Mouvant (colonnes docx p.10).
|
||||
- [ ] Controllers custom `#[Route(priority: 1)]` (conflit API Platform `{id}`) ; `Content-Disposition`.
|
||||
**Tests à prévoir** : PHPUnit — 200 + en-tête fichier ; respect des filtres.
|
||||
**Tips** : PhpSpreadsheet déjà présent.
|
||||
|
||||
### 1.11 — Tests PHPUnit RG-4.01→4.14 + capture contrat JSON (DoD)
|
||||
**Position** : 1.11 • Suit : Export • Précède : Page Répertoire
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Spec liée** : [`spec-back.md § 4.0.bis / 8.1`](./spec-back.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Matrice RG-4.01→4.14 couverte (§ 8.1) + RBAC par rôle (Compta/Usine → 403).
|
||||
- [ ] `CarrierSerializationContractTest` : capture JSON réel **liste + détail** ; `prices[].client`/`.supplier`/sites **embarqués** (pas IRI) ; `qualimatCarrier` embarqué ; `isArchived` présent.
|
||||
- [ ] Anti-N+1 liste ; pagination Hydra ; audit (`entity_type='Carrier'`) ; `AuditableEntitiesHaveI18nLabelTest` vert.
|
||||
- [ ] `CarrierFixtures` idempotent (§ 8.4) : transporteur QUALIMAT (validité passée), AUTRE+décharge, affrété, LIOT, complet (contacts/adresses/prix CLIENT+FOURNISSEUR), 1 archivé.
|
||||
**Tests à prévoir** : suite complète `make test` verte.
|
||||
**Tips** : coller les JSON capturés dans § 4.0.bis (DoD avant front).
|
||||
|
||||
### 1.12 — Page Répertoire `/carriers` (datatable, filtres, export)
|
||||
**Position** : 1.12 • Suit : Tests back • Précède : Page Ajouter
|
||||
**Tag** : Frontend • **Effort** : M
|
||||
**Spec liée** : [`spec-front.md § Datatable / Filtres`](./spec-front.md) · Figma `1132-45377`
|
||||
**Critères d'acceptation** :
|
||||
- [ ] `<MalioDataTable>` + `usePaginatedList<Carrier>({url:'/carriers'})` ; colonnes Nom / Certification / Date de validité / Dernière activité.
|
||||
- [ ] **RG-4.04** : date de validité QUALIMAT < aujourd'hui → **fond rouge**.
|
||||
- [ ] Filtres (`search`, `certificationType`, `includeArchived`) → `setFilters` (page 1) ; **état 100 % local** (règle n°6).
|
||||
- [ ] Boutons « + Ajouter » (si `manage`) / « Filtrer » / « Exporter » (XLSX) ; clic ligne → Consultation.
|
||||
**Tests à prévoir** : Vitest — `usePaginatedList` (Hydra, exclusion archivés).
|
||||
**Tips** : `useApi()` obligatoire ; pas de persistance URL.
|
||||
|
||||
### 1.13 — Page Ajouter `/carriers/new` (layout, onglets, formulaire principal POST)
|
||||
**Position** : 1.13 • Suit : Répertoire • Précède : Saisie assistée QUALIMAT
|
||||
**Tag** : Frontend • **Effort** : M
|
||||
**Spec liée** : [`spec-front.md § Écran Ajouter / Formulaire principal`](./spec-front.md) · Figma node `1132-45382` (Ajouter – Qualimat)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Layout + barre d'onglets `Qualimat · Adresses · Contacts · Prix` ; validation incrémentale (onglet suivant accessible après validation).
|
||||
- [ ] Formulaire principal (Nom, Liste certification, Affréter, …) → `POST /api/carriers` ; succès → bascule onglet + champs readonly.
|
||||
- [ ] `useFormErrors` : mapping 422 inline par champ ; `{ toast:false }`.
|
||||
**Tests à prévoir** : Vitest — `useCarrierForm` (workflow par onglet, POST principal).
|
||||
**Tips** : miroir `useSupplierForm`/`useProviderForm`.
|
||||
|
||||
### 1.14 — Saisie assistée QUALIMAT + champs conditionnels
|
||||
**Position** : 1.14 • Suit : Page Ajouter • Précède : Onglet Adresses
|
||||
**Tag** : Frontend • **Effort** : M
|
||||
**Spec liée** : [`spec-front.md § Formulaire principal / Onglet Qualimat`](./spec-front.md) · RG-4.01→4.03 · Figma nodes `1132-50717` (Affréter), `1132-50982` (AUTRE→Décharge), `1132-45593` (LIOT)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] **RG-4.01** : saisie du nom → `GET /api/qualimat_carriers?search=` → modal « Êtes-vous sûr… » → copie Nom + certification (`QUALIMAT`, readonly) + adresse + FK conservée.
|
||||
- [ ] **Cas LIOT** : nom `LIOT` → champ immatriculations seul, autres masqués.
|
||||
- [ ] **RG-4.02** : certification `AUTRE` → champ Décharge visible **et obligatoire** (upload).
|
||||
- [ ] **RG-4.03** : « Affréter » coché → indexation / benne-fond mouvant / volume visibles et obligatoires.
|
||||
**Tests à prévoir** : Vitest — affichage conditionnel (Affréter, AUTRE, LIOT) ; copie QUALIMAT.
|
||||
**Tips** : `useQualimatSearch()` ; `useUpload()` (ticket 1.19) pour la décharge.
|
||||
|
||||
### 1.15 — Onglet Adresses (autocomplete BAN)
|
||||
**Position** : 1.15 • Suit : Saisie QUALIMAT • Précède : Onglet Contacts
|
||||
**Tag** : Frontend • **Effort** : M
|
||||
**Spec liée** : [`spec-front.md § Onglet Adresses`](./spec-front.md) · RG-4.05→4.07 · Figma node `1132-45670`
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Bloc adresse (Pays/CP/Ville/Adresse/complément) → `PATCH /api/carriers/{id}/addresses`.
|
||||
- [ ] **RG-4.06** : `useAddressAutocomplete()` (BAN) — ville auto depuis CP, dégradé texte libre.
|
||||
- [ ] **RG-4.05** : champs préremplis si QUALIMAT ; obligatoires si affrété. **RG-4.07** : pas de bouton Valider si QUALIMAT.
|
||||
**Tests à prévoir** : Vitest — autocomplete nominal + dégradé (réutilisation M1/M2/M3).
|
||||
**Tips** : ne pas réécrire `useAddressAutocomplete()`.
|
||||
|
||||
### 1.16 — Onglet Contacts
|
||||
**Position** : 1.16 • Suit : Adresses • Précède : Onglet Prix
|
||||
**Tag** : Frontend • **Effort** : S
|
||||
**Spec liée** : [`spec-front.md § Onglet Contacts`](./spec-front.md) · RG-4.08 · Figma node `1132-45756`
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Blocs contact (Nom/Prénom/Fonction/Téléphone x1-2/Email) → `PATCH /api/carriers/{id}/contacts`.
|
||||
- [ ] **RG-4.08** : « + Nouveau contact » bloqué tant que le bloc courant est vide ; suppression avec modal.
|
||||
**Tests à prévoir** : Vitest — règle « ≥ 1 champ », max 2 téléphones.
|
||||
**Tips** : `mapViolationsToRecord` par ligne (pattern collections M1/M2/M3).
|
||||
|
||||
### 1.17 — Onglet Prix (Client/Fournisseur, sites)
|
||||
**Position** : 1.17 • Suit : Contacts • Précède : Consultation/Modification
|
||||
**Tag** : Frontend • **Effort** : M
|
||||
**Spec liée** : [`spec-front.md § Onglet Prix`](./spec-front.md) · RG-4.09→4.11 · Figma node `1132-45859`
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Radio `direction` (Client/Fournisseur) → bascule des champs (**RG-4.09**).
|
||||
- [ ] **RG-4.10** (Client) : Client + Adresse de livraison (du client) + Adresse de départ (86/17/82).
|
||||
- [ ] **RG-4.11** (Fournisseur) : Fournisseur + Adresse d'approvisionnement + Adresse de livraison (86/17/82).
|
||||
- [ ] Communs : Benne/FM, Forfait/Tonne, Prix (`MalioInputAmount`), État du prix → `PATCH /api/carriers/{id}/prices`.
|
||||
**Tests à prévoir** : Vitest — bascule Client/Fournisseur, champs requis.
|
||||
**Tips** : selects clients/fournisseurs/sites via endpoints existants (security élargie § 4.8).
|
||||
|
||||
### 1.18 — Pages Consultation + Modification
|
||||
**Position** : 1.18 • Suit : Onglet Prix • Précède : Upload/i18n
|
||||
**Tag** : Frontend • **Effort** : M
|
||||
**Spec liée** : [`spec-front.md § Consultation / Modification`](./spec-front.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Consultation readonly (ouvre sur Adresses) ; flèche retour ; « Modifier » (si `manage`) ; « Archiver » (Admin) → PATCH `isArchived`.
|
||||
- [ ] Onglet Prix consultation = tableau regroupé Benne/FM + bouton Exporter (XLSX).
|
||||
- [ ] Modification = mêmes formulaires, champs pré-remplis, PATCH partiel par onglet.
|
||||
**Tests à prévoir** : Vitest — `useCarrier(id)` peuple les écrans depuis une seule réponse ; visibilité boutons par permission.
|
||||
**Tips** : « Restaurer » remplace « Archiver » sur un archivé.
|
||||
|
||||
### 1.19 — Upload front (`useUpload`) + i18n + libellés audit
|
||||
**Position** : 1.19 • Suit : Consultation/Modification • Précède : —
|
||||
**Tag** : Frontend • **Effort** : S
|
||||
**Spec liée** : [`spec-back.md § 2.7 / 2.8`](./spec-back.md) · [`spec-front.md § Composables`](./spec-front.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Composable `useUpload()` : `POST /api/uploaded_documents` (multipart) → IRI posée sur `carrier.dischargeDocument` (RG-4.02).
|
||||
- [ ] Clés i18n : libellés certification, sidebar (`sidebar.transport.*`), **libellés audit** `audit.entity.transport_carrier/carrieraddress/carriercontact/carrierprice`.
|
||||
- [ ] `<MalioInputUpload>` (exception documentée si type non couvert).
|
||||
**Tests à prévoir** : Vitest — `useUpload` (succès + erreur MIME).
|
||||
**Tips** : `AuditableEntitiesHaveI18nLabelTest` exige les clés audit.
|
||||
|
||||
---
|
||||
|
||||
## Actions Lesstime (à exécuter au feu vert de Matthieu)
|
||||
|
||||
1. `create-group` projectId 6, title « M4 — Répertoire transporteurs » → récupérer l'`id`.
|
||||
2. `create-task` ×19 (statut `Prêt à dev` = 6, priorité Moyen=2, effort dans la description), dans l'ordre 1.1 → 1.19 :
|
||||
- Tickets **1.1 → 1.11** (Backend, tag `3`) → **assigné à Matthieu**.
|
||||
- Tickets **1.12 → 1.19** (Frontend, tag `2`) → **assigné à Tristan**.
|
||||
3. Mettre à jour le frontmatter des specs (`lesstime_taskgroup_id`) + lien du groupe.
|
||||
|
||||
> Au push : récupérer les `userId` via `list-users` (Matthieu = `5` selon le référentiel ; Tristan à confirmer) pour renseigner l'assignation à la création.
|
||||
@@ -34,6 +34,10 @@
|
||||
"section": "Technique",
|
||||
"providers": "Répertoire prestataires"
|
||||
},
|
||||
"transport": {
|
||||
"section": "Transport",
|
||||
"carriers": "Répertoire transporteurs"
|
||||
},
|
||||
"core": {
|
||||
"roles": "Gestion des rôles",
|
||||
"users": "Utilisateurs",
|
||||
@@ -390,9 +394,32 @@
|
||||
},
|
||||
"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",
|
||||
@@ -404,6 +431,7 @@
|
||||
"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."
|
||||
},
|
||||
@@ -432,19 +460,37 @@
|
||||
"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 ?"
|
||||
"address": "Supprimer cette adresse ?",
|
||||
"rib": "Supprimer ce RIB ?"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"error": "Une erreur est survenue. Réessayez.",
|
||||
"exportError": "L'export du répertoire prestataires a échoué. Réessayez.",
|
||||
"createSuccess": "Prestataire créé avec succès",
|
||||
"updateSuccess": "Prestataire mis à jour avec succès"
|
||||
"updateSuccess": "Prestataire mis à jour avec succès",
|
||||
"addComplete": "Prestataire ajouté",
|
||||
"archiveSuccess": "Prestataire archivé avec succès",
|
||||
"restoreSuccess": "Prestataire restauré avec succès"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -506,7 +552,11 @@
|
||||
"technique_provider": "Prestataire",
|
||||
"technique_provideraddress": "Adresse prestataire",
|
||||
"technique_providercontact": "Contact prestataire",
|
||||
"technique_providerrib": "RIB prestataire"
|
||||
"technique_providerrib": "RIB prestataire",
|
||||
"transport_carrier": "Transporteur",
|
||||
"transport_carrieraddress": "Adresse transporteur",
|
||||
"transport_carriercontact": "Contact transporteur",
|
||||
"transport_carrierprice": "Prix transporteur"
|
||||
},
|
||||
"empty": "Aucune activité enregistrée",
|
||||
"no_results": "Aucun résultat pour ces filtres",
|
||||
|
||||
@@ -157,12 +157,16 @@
|
||||
<!-- Onglet Contact -->
|
||||
<template #contact>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||
bloc enregistre. -->
|
||||
<ClientContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="contact.id ?? `new-${index}`"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
:removable="contacts.length > 1"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="businessReadonly"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -199,7 +203,7 @@
|
||||
:site-options="siteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="addresses.length > 1"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="businessReadonly"
|
||||
:errors="addressErrors[index]"
|
||||
@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)]"
|
||||
>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -440,6 +444,7 @@ import {
|
||||
type RibFormDraft,
|
||||
} from '~/modules/commercial/types/clientForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
|
||||
// Masques de saisie (la normalisation finale reste serveur).
|
||||
@@ -490,10 +495,6 @@ const contacts = ref<ContactFormDraft[]>([])
|
||||
const addresses = ref<AddressFormDraft[]>([])
|
||||
const ribs = ref<RibFormDraft[]>([])
|
||||
|
||||
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
|
||||
const removedContactIds = ref<number[]>([])
|
||||
const removedAddressIds = ref<number[]>([])
|
||||
const removedRibIds = ref<number[]>([])
|
||||
|
||||
const mainSubmitting = ref(false)
|
||||
const tabSubmitting = ref(false)
|
||||
@@ -754,32 +755,31 @@ function addContact(): void {
|
||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||
}
|
||||
|
||||
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
|
||||
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
|
||||
// local. Echec serveur : bloc conserve + erreur remontee.
|
||||
function askRemoveContact(index: number): void {
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
|
||||
const removed = contacts.value[index]
|
||||
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
||||
contacts.value.splice(index, 1)
|
||||
contactErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
||||
})
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => removeCollectionRow({
|
||||
rows: contacts.value,
|
||||
errors: contactErrors.value,
|
||||
index,
|
||||
endpoint: '/client_contacts',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyContact,
|
||||
onError: showError,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Contact : DELETE des contacts retires (existants), puis
|
||||
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
||||
* collection contacts (endpoints client_contact dedies).
|
||||
* Valide l'onglet Contact : POST/PATCH des blocs restants sur la sous-ressource.
|
||||
* Strictement scope a la collection contacts (endpoints client_contact dedies). La
|
||||
* suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
|
||||
*/
|
||||
async function submitContacts(): Promise<void> {
|
||||
if (businessReadonly.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
contactErrors.value = []
|
||||
try {
|
||||
for (const id of removedContactIds.value) {
|
||||
await api.delete(`/client_contacts/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedContactIds.value = []
|
||||
|
||||
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
|
||||
// amorces neuves vides (ex. tous les contacts existants supprimes), on ne
|
||||
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
|
||||
@@ -836,14 +836,15 @@ function addAddress(): void {
|
||||
}
|
||||
|
||||
function askRemoveAddress(index: number): void {
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
|
||||
const removed = addresses.value[index]
|
||||
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
||||
addresses.value.splice(index, 1)
|
||||
addressErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
||||
})
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => removeCollectionRow({
|
||||
rows: addresses.value,
|
||||
errors: addressErrors.value,
|
||||
index,
|
||||
endpoint: '/client_addresses',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyAddress,
|
||||
onError: showError,
|
||||
}))
|
||||
}
|
||||
|
||||
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> {
|
||||
if (businessReadonly.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
addressErrors.value = []
|
||||
try {
|
||||
for (const id of removedAddressIds.value) {
|
||||
await api.delete(`/client_addresses/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedAddressIds.value = []
|
||||
|
||||
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
|
||||
const hasError = await submitRows(
|
||||
addresses.value,
|
||||
@@ -937,29 +933,32 @@ function addRib(): void {
|
||||
if (canAddRib.value) ribs.value.push(emptyRib())
|
||||
}
|
||||
|
||||
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
|
||||
// d'une LCR (RG-1.13) -> 409 remonte via showError (message back), bloc conserve.
|
||||
function askRemoveRib(index: number): void {
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
|
||||
const removed = ribs.value[index]
|
||||
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
||||
ribs.value.splice(index, 1)
|
||||
ribErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
|
||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||
})
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => removeCollectionRow({
|
||||
rows: ribs.value,
|
||||
errors: ribErrors.value,
|
||||
index,
|
||||
endpoint: '/client_ribs',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyRib,
|
||||
onError: showError,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
|
||||
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
|
||||
* back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back
|
||||
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
||||
* back). Les RIB crees d'abord : le back valide RG-1.13 (LCR => au moins un RIB
|
||||
* persiste) sur le PATCH scalaires.
|
||||
*
|
||||
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
|
||||
* plus de DELETE differe ici.
|
||||
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
||||
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
||||
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement
|
||||
* de type de reglement. Aucun champ main/information dans le payload (mode strict
|
||||
* RG-1.28 : sinon 403 sur tout le payload).
|
||||
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-1.28 :
|
||||
* sinon 403 sur tout le payload).
|
||||
*/
|
||||
async function submitAccounting(): Promise<void> {
|
||||
if (accountingReadonly.value || tabSubmitting.value) return
|
||||
@@ -1013,14 +1012,6 @@ async function submitAccounting(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
|
||||
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
|
||||
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
|
||||
for (const id of removedRibIds.value) {
|
||||
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedRibIds.value = []
|
||||
|
||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
@@ -156,12 +156,16 @@
|
||||
<!-- Onglet Contact -->
|
||||
<template #contact>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||
bloc enregistre. -->
|
||||
<ClientContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
:removable="index > 0"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="isValidated('contact')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -198,7 +202,7 @@
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="index > 0"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="isValidated('address')"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@@ -303,7 +307,7 @@
|
||||
>
|
||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -417,6 +421,7 @@ import {
|
||||
type RibFormDraft,
|
||||
} from '~/modules/commercial/types/clientForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||
|
||||
// Masques de saisie (la normalisation finale reste serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
|
||||
@@ -126,12 +126,16 @@
|
||||
<!-- Onglet Contacts -->
|
||||
<template #contacts>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||
bloc enregistre. -->
|
||||
<SupplierContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="contact.id ?? `new-${index}`"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||
:removable="contacts.length > 1"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="businessReadonly"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -168,7 +172,7 @@
|
||||
:site-options="siteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="addresses.length > 1"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="businessReadonly"
|
||||
:errors="addressErrors[index]"
|
||||
@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)]"
|
||||
>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -407,6 +411,7 @@ import {
|
||||
type SupplierRibFormDraft,
|
||||
} from '~/modules/commercial/types/supplierForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
|
||||
// Masques de saisie (la normalisation finale reste serveur).
|
||||
@@ -456,10 +461,6 @@ const contacts = ref<SupplierContactFormDraft[]>([])
|
||||
const addresses = ref<SupplierAddressFormDraft[]>([])
|
||||
const ribs = ref<SupplierRibFormDraft[]>([])
|
||||
|
||||
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
|
||||
const removedContactIds = ref<number[]>([])
|
||||
const removedAddressIds = ref<number[]>([])
|
||||
const removedRibIds = ref<number[]>([])
|
||||
|
||||
const mainSubmitting = ref(false)
|
||||
const tabSubmitting = ref(false)
|
||||
@@ -653,32 +654,31 @@ function addContact(): void {
|
||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||
}
|
||||
|
||||
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
|
||||
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
|
||||
// local. Echec serveur : bloc conserve + erreur remontee.
|
||||
function askRemoveContact(index: number): void {
|
||||
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => {
|
||||
const removed = contacts.value[index]
|
||||
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
||||
contacts.value.splice(index, 1)
|
||||
contactErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
||||
})
|
||||
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => removeCollectionRow({
|
||||
rows: contacts.value,
|
||||
errors: contactErrors.value,
|
||||
index,
|
||||
endpoint: '/supplier_contacts',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyContact,
|
||||
onError: showError,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Contacts : DELETE des contacts retires (existants), puis
|
||||
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
||||
* collection contacts (endpoints supplier_contact dedies).
|
||||
* Valide l'onglet Contacts : POST/PATCH des blocs restants sur la sous-ressource.
|
||||
* Strictement scope a la collection contacts (endpoints supplier_contact dedies).
|
||||
* La suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
|
||||
*/
|
||||
async function submitContacts(): Promise<void> {
|
||||
if (businessReadonly.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
contactErrors.value = []
|
||||
try {
|
||||
for (const id of removedContactIds.value) {
|
||||
await api.delete(`/supplier_contacts/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedContactIds.value = []
|
||||
|
||||
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
|
||||
// amorces neuves vides, on les soumet -> 422 RG-2.04 inline (nom OU prenom).
|
||||
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
|
||||
@@ -726,14 +726,15 @@ function addAddress(): void {
|
||||
}
|
||||
|
||||
function askRemoveAddress(index: number): void {
|
||||
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => {
|
||||
const removed = addresses.value[index]
|
||||
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
||||
addresses.value.splice(index, 1)
|
||||
addressErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
||||
})
|
||||
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => removeCollectionRow({
|
||||
rows: addresses.value,
|
||||
errors: addressErrors.value,
|
||||
index,
|
||||
endpoint: '/supplier_addresses',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyAddress,
|
||||
onError: showError,
|
||||
}))
|
||||
}
|
||||
|
||||
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> {
|
||||
if (businessReadonly.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
addressErrors.value = []
|
||||
try {
|
||||
for (const id of removedAddressIds.value) {
|
||||
await api.delete(`/supplier_addresses/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedAddressIds.value = []
|
||||
|
||||
const hasError = await submitRows(
|
||||
addresses.value,
|
||||
addressErrors,
|
||||
@@ -826,15 +822,18 @@ function addRib(): void {
|
||||
if (canAddRib.value) ribs.value.push(emptyRib())
|
||||
}
|
||||
|
||||
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
|
||||
// d'une LCR (RG-2.08) -> 409 remonte via showError (message back), bloc conserve.
|
||||
function askRemoveRib(index: number): void {
|
||||
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => {
|
||||
const removed = ribs.value[index]
|
||||
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
||||
ribs.value.splice(index, 1)
|
||||
ribErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
|
||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||
})
|
||||
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => removeCollectionRow({
|
||||
rows: ribs.value,
|
||||
errors: ribErrors.value,
|
||||
index,
|
||||
endpoint: '/supplier_ribs',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyRib,
|
||||
onError: showError,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -843,11 +842,12 @@ function askRemoveRib(index: number): void {
|
||||
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
|
||||
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
|
||||
*
|
||||
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
|
||||
* plus de DELETE differe ici.
|
||||
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
|
||||
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
|
||||
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
|
||||
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le
|
||||
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
|
||||
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-2.16 :
|
||||
* sinon 403 sur tout le payload).
|
||||
*/
|
||||
async function submitAccounting(): Promise<void> {
|
||||
if (accountingReadonly.value || tabSubmitting.value) return
|
||||
@@ -897,14 +897,6 @@ async function submitAccounting(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
|
||||
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
|
||||
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
|
||||
for (const id of removedRibIds.value) {
|
||||
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedRibIds.value = []
|
||||
|
||||
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
|
||||
}
|
||||
catch (e) {
|
||||
|
||||
@@ -121,12 +121,16 @@
|
||||
<!-- Onglet Contacts -->
|
||||
<template #contacts>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
|
||||
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
|
||||
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
|
||||
bloc enregistre. -->
|
||||
<SupplierContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||
:removable="index > 0"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="isValidated('contacts')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -163,7 +167,7 @@
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="index > 0"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="isValidated('addresses')"
|
||||
:errors="addressErrors[index]"
|
||||
@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)]"
|
||||
>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
||||
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -380,6 +384,7 @@ import {
|
||||
type SupplierRibFormDraft,
|
||||
} from '~/modules/commercial/types/supplierForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||
|
||||
// Masques de saisie (la normalisation finale reste serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
|
||||
@@ -19,8 +19,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockPost = vi.hoisted(() => vi.fn())
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
// Permission accounting.view pilotable par test (presence de l'onglet Comptabilite).
|
||||
const permState = vi.hoisted(() => ({ accountingView: false }))
|
||||
// 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(),
|
||||
@@ -37,7 +37,11 @@ vi.stubGlobal('useToast', () => ({
|
||||
info: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal('usePermissions', () => ({
|
||||
can: (perm: string) => perm === 'technique.providers.accounting.view' ? permState.accountingView : true,
|
||||
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')
|
||||
@@ -62,16 +66,17 @@ describe('useProviderForm', () => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
permState.accountingView = false
|
||||
permState.accountingManage = false
|
||||
})
|
||||
|
||||
it('RG-3.03/RG-3.09 (front) : bloque le POST si aucun site / aucune categorie', async () => {
|
||||
it('front : formulaire principal vide -> erreurs sur nom + site + categorie, pas de POST', async () => {
|
||||
const form = useProviderForm()
|
||||
form.main.companyName = 'Maintenance Pro'
|
||||
|
||||
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)
|
||||
@@ -117,18 +122,17 @@ describe('useProviderForm', () => {
|
||||
expect(form.unlockedIndex.value).toBe(0)
|
||||
})
|
||||
|
||||
it('omet companyName vide du payload (laisse la 422 NotBlank back mordre)', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 1, companyName: null })
|
||||
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]
|
||||
|
||||
await form.submitMain()
|
||||
const created = await form.submitMain()
|
||||
|
||||
const body = (mockPost.mock.calls[0] ?? [])[1] as Record<string, unknown>
|
||||
expect(body).not.toHaveProperty('companyName')
|
||||
expect(body).toEqual({ categories: [CAT_MAINT], sites: [SITE_86] })
|
||||
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 () => {
|
||||
@@ -208,6 +212,7 @@ describe('useProviderForm — onglet Contact (ERP-142)', () => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
permState.accountingView = false
|
||||
permState.accountingManage = false
|
||||
})
|
||||
|
||||
/** Place le formulaire en etat « prestataire cree » (onglet Contact accessible). */
|
||||
@@ -315,6 +320,7 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
permState.accountingView = false
|
||||
permState.accountingManage = false
|
||||
})
|
||||
|
||||
/** Place le formulaire en etat « prestataire cree » (onglet Adresse accessible). */
|
||||
@@ -406,3 +412,242 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => {
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,38 @@
|
||||
import { computed, reactive, ref, type Ref } from 'vue'
|
||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||
import { mapViolationsToRecord } from '~/shared/utils/api'
|
||||
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) —
|
||||
@@ -61,6 +74,16 @@ export function useProviderForm() {
|
||||
// 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)
|
||||
@@ -72,6 +95,7 @@ export function useProviderForm() {
|
||||
|
||||
// ── 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).
|
||||
@@ -79,6 +103,9 @@ export function useProviderForm() {
|
||||
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
|
||||
@@ -96,6 +123,10 @@ export function useProviderForm() {
|
||||
*/
|
||||
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
|
||||
@@ -180,12 +211,55 @@ export function useProviderForm() {
|
||||
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]
|
||||
@@ -241,10 +315,11 @@ export function useProviderForm() {
|
||||
// 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 est vide (RG-3.04).
|
||||
// « + 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 && !isProviderContactBlank(last)
|
||||
return last !== undefined && isProviderContactNamed(last)
|
||||
})
|
||||
|
||||
function addContact(): void {
|
||||
@@ -253,9 +328,18 @@ export function useProviderForm() {
|
||||
}
|
||||
}
|
||||
|
||||
function removeContact(index: number): void {
|
||||
contacts.value.splice(index, 1)
|
||||
contactErrors.value.splice(index, 1)
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,9 +407,17 @@ export function useProviderForm() {
|
||||
}
|
||||
}
|
||||
|
||||
function removeAddress(index: number): void {
|
||||
addresses.value.splice(index, 1)
|
||||
addressErrors.value.splice(index, 1)
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -370,6 +462,135 @@ export function useProviderForm() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
@@ -380,10 +601,12 @@ export function useProviderForm() {
|
||||
mainErrors,
|
||||
// onglets
|
||||
canAccountingView,
|
||||
canAccountingManage,
|
||||
tabKeys,
|
||||
activeTab,
|
||||
unlockedIndex,
|
||||
validated,
|
||||
editMode,
|
||||
isValidated,
|
||||
// contacts
|
||||
contacts,
|
||||
@@ -399,10 +622,22 @@ export function useProviderForm() {
|
||||
addAddress,
|
||||
removeAddress,
|
||||
submitAddresses,
|
||||
// comptabilite
|
||||
accounting,
|
||||
ribs,
|
||||
accountingErrors,
|
||||
ribErrors,
|
||||
accountingReadonly,
|
||||
setPaymentType,
|
||||
canAddRib,
|
||||
addRib,
|
||||
removeRib,
|
||||
submitAccounting,
|
||||
// actions
|
||||
validateMainFront,
|
||||
buildMainPayload,
|
||||
submitMain,
|
||||
updateMain,
|
||||
patchProvider,
|
||||
completeTab,
|
||||
submitRows,
|
||||
|
||||
@@ -28,10 +28,20 @@ export interface RefOption {
|
||||
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
|
||||
@@ -55,6 +65,11 @@ export function useProviderReferentials() {
|
||||
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>(
|
||||
@@ -88,10 +103,34 @@ export function useProviderReferentials() {
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
@@ -63,11 +63,15 @@
|
||||
<!-- 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="index > 0"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="isValidated('contact')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@@ -85,7 +89,7 @@
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.form.submit')"
|
||||
:disabled="tabSubmitting"
|
||||
:disabled="tabSubmitting || providerId === null"
|
||||
@click="onSubmitContacts"
|
||||
/>
|
||||
</div>
|
||||
@@ -102,7 +106,7 @@
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="index > 0"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="isValidated('address')"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@@ -121,13 +125,142 @@
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.form.submit')"
|
||||
:disabled="tabSubmitting"
|
||||
:disabled="tabSubmitting || providerId === null"
|
||||
@click="onSubmitAddresses"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="canAccountingView" #accounting><ComingSoonPlaceholder /></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). -->
|
||||
@@ -158,7 +291,15 @@
|
||||
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()
|
||||
@@ -178,6 +319,7 @@ const referentials = useProviderReferentials()
|
||||
|
||||
const {
|
||||
main,
|
||||
providerId,
|
||||
mainLocked,
|
||||
mainSubmitting,
|
||||
mainErrors,
|
||||
@@ -200,6 +342,16 @@ const {
|
||||
addAddress,
|
||||
removeAddress,
|
||||
submitAddresses,
|
||||
accounting,
|
||||
ribs,
|
||||
accountingErrors,
|
||||
ribErrors,
|
||||
accountingReadonly,
|
||||
setPaymentType,
|
||||
canAddRib,
|
||||
addRib,
|
||||
removeRib,
|
||||
submitAccounting,
|
||||
} = useProviderForm()
|
||||
|
||||
/** Retour vers le repertoire prestataires (fleche d'en-tete). */
|
||||
@@ -216,15 +368,33 @@ function apiErrorMessage(error: unknown): string {
|
||||
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 ; toast de succes si l'onglet a ete finalise. */
|
||||
/** 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) {
|
||||
toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||
onTabSaved('contact')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,14 +437,14 @@ function onAddressDegraded(): void {
|
||||
})
|
||||
}
|
||||
|
||||
/** Valide l'onglet Adresse ; toast de succes si l'onglet a ete finalise. */
|
||||
/** 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) {
|
||||
toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||
onTabSaved('address')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +452,43 @@ 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,
|
||||
@@ -320,5 +527,9 @@ const tabs = computed(() => tabKeys.value.map((key, index) => ({
|
||||
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>
|
||||
|
||||
@@ -124,3 +124,54 @@ export function emptyProviderAddress(): ProviderAddressFormDraft {
|
||||
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...' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
buildProviderContactPayload,
|
||||
hasAtLeastOneFilledContact,
|
||||
isProviderContactBlank,
|
||||
isProviderContactNamed,
|
||||
} from '../providerContact'
|
||||
import { emptyProviderContact } from '~/modules/technique/types/providerForm'
|
||||
|
||||
@@ -34,15 +35,28 @@ describe('providerContact helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAtLeastOneFilledContact (RG-3.12)', () => {
|
||||
it('false si tous les blocs sont vides', () => {
|
||||
expect(hasAtLeastOneFilledContact([emptyProviderContact(), emptyProviderContact()])).toBe(false)
|
||||
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('true des qu\'un bloc porte une donnee', () => {
|
||||
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(), email: 'a@b.fr' },
|
||||
{ ...emptyProviderContact(), lastName: 'Dupont' },
|
||||
])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -32,12 +32,21 @@ export function isProviderContactBlank(contact: ProviderContactFormDraft): boole
|
||||
].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
|
||||
* bloc non vide (au moins un contact valide).
|
||||
* contact nomme (prenom ou nom).
|
||||
*/
|
||||
export function hasAtLeastOneFilledContact(contacts: ProviderContactFormDraft[]): boolean {
|
||||
return contacts.some(contact => !isProviderContactBlank(contact))
|
||||
return contacts.some(isProviderContactNamed)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -95,6 +95,13 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
'technique.providers.archive',
|
||||
// Transport — Repertoire transporteurs (M4, ERP-153). Meme logique :
|
||||
// mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE
|
||||
// n°7). transport.carriers.view n'ajoute pas de lien dans la section
|
||||
// Administration, donc expectedAdminLinks reste inchange.
|
||||
'transport.carriers.view',
|
||||
'transport.carriers.manage',
|
||||
'transport.carriers.archive',
|
||||
],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||
},
|
||||
|
||||
@@ -232,6 +232,7 @@ test-db-setup:
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_carrier_name_active ON carrier (LOWER(name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
|
||||
fixtures:
|
||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||
@@ -250,6 +251,22 @@ sync-permissions:
|
||||
seed-rbac:
|
||||
$(SYMFONY_CONSOLE) --no-interaction app:seed-rbac
|
||||
|
||||
# Synchronise le referentiel des transporteurs QUALIMAT (ERP-39) : upsert sur
|
||||
# le SIRET + soft-delete des absents + journal. Idempotent (refresh complet),
|
||||
# prevu pour un cron quotidien.
|
||||
# Options : --dry-run (analyse sans ecriture), --file=<chemin.json> (source
|
||||
# locale au lieu de l'API), --ppp=<n> (taille de page API, defaut 10000).
|
||||
qualimat-sync:
|
||||
$(SYMFONY_CONSOLE) --no-interaction app:qualimat:sync
|
||||
|
||||
# Synchronise le referentiel des codes IDTF (ERP-149) depuis l'export Excel
|
||||
# icrt-idtf.com : upsert sur (schema, idtf_number) + soft-delete + journal.
|
||||
# Idempotent (refresh complet).
|
||||
# Options : --schema=road|water (defaut road), --dry-run (analyse sans
|
||||
# ecriture), --file=<chemin.xlsx> (source locale au lieu du telechargement).
|
||||
idtf-sync:
|
||||
$(SYMFONY_CONSOLE) --no-interaction app:idtf:sync
|
||||
|
||||
# Attention, supprime votre bdd local
|
||||
db-reset:
|
||||
$(DOCKER_COMPOSE) down -v
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-149 (Module Transport) : referentiel des codes IDTF (regimes de nettoyage
|
||||
* transport).
|
||||
*
|
||||
* Tables alimentees par la commande `app:idtf:sync` (parsing de l'export Excel
|
||||
* icrt-idtf.com, upsert sur (schema, idtf_number) + soft-delete + journal).
|
||||
* Aucune FK cross-module : migration au namespace racine `DoctrineMigrations`.
|
||||
*/
|
||||
final class Version20260612160000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-149 : tables idtf_product + idtf_sync_log (referentiel codes IDTF, synchro console depuis l\'export Excel).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE idtf_product (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
idtf_number INTEGER NOT NULL,
|
||||
schema VARCHAR(8) NOT NULL,
|
||||
product_group VARCHAR(255) DEFAULT NULL,
|
||||
name TEXT NOT NULL,
|
||||
cleaning_regime VARCHAR(64) NOT NULL,
|
||||
important_requirements TEXT DEFAULT NULL,
|
||||
mandatory_date DATE DEFAULT NULL,
|
||||
related_products TEXT DEFAULT NULL,
|
||||
formula VARCHAR(255) DEFAULT NULL,
|
||||
eural_code VARCHAR(64) DEFAULT NULL,
|
||||
cas_numbers JSONB DEFAULT '[]' NOT NULL,
|
||||
footnotes TEXT DEFAULT NULL,
|
||||
source_export_date DATE NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
last_synced_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT uq_idtf_product_schema_number UNIQUE (schema, idtf_number),
|
||||
CONSTRAINT chk_idtf_product_schema CHECK (schema IN ('road', 'water'))
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_idtf_product_active ON idtf_product (schema, is_active)');
|
||||
|
||||
$this->comment('idtf_product', '_table', "Referentiel des codes IDTF (marchandise + regime de nettoyage transport), synchronise depuis l'export Excel icrt-idtf.com.");
|
||||
$this->comment('idtf_product', 'id', 'Cle technique auto-incrementee.');
|
||||
$this->comment('idtf_product', 'idtf_number', 'Numero IDTF de la marchandise (identifiant metier source). Unique par schema.');
|
||||
$this->comment('idtf_product', 'schema', "Mode de transport / schema IDTF : 'road' (routier) ou 'water' (fluvial). Discriminant d'unicite avec idtf_number.");
|
||||
$this->comment('idtf_product', 'product_group', "Groupe de produit (colonne Product Group de l'export). Nullable.");
|
||||
$this->comment('idtf_product', 'name', "Nom de la marchandise (libelle FR de l'export).");
|
||||
$this->comment('idtf_product', 'cleaning_regime', 'Regime de nettoyage minimal exige (A, B, C, Interdit, ...).');
|
||||
$this->comment('idtf_product', 'important_requirements', 'Exigences importantes associees. Nullable.');
|
||||
$this->comment('idtf_product', 'mandatory_date', "Date d'application obligatoire du regime (convertie depuis dd-mm-yyyy). Nullable.");
|
||||
$this->comment('idtf_product', 'related_products', 'Produits apparentes (texte libre). Nullable.');
|
||||
$this->comment('idtf_product', 'formula', 'Formule chimique de la marchandise. Nullable.');
|
||||
$this->comment('idtf_product', 'eural_code', 'Code EURAL (dechet) associe. Nullable.');
|
||||
$this->comment('idtf_product', 'cas_numbers', 'Liste des numeros CAS (JSONB), eclatee depuis la cellule "Numero CAS" separee par ";". Tableau vide si absent.');
|
||||
$this->comment('idtf_product', 'footnotes', "Annotations / notes de bas de page de l'export. Nullable.");
|
||||
$this->comment('idtf_product', 'source_export_date', 'Date d\'export du fichier source (preambule "Export date:").');
|
||||
$this->comment('idtf_product', 'is_active', 'Faux = ligne absente du dernier export (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.');
|
||||
$this->comment('idtf_product', 'last_synced_at', 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE idtf_sync_log (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
schema VARCHAR(8) NOT NULL,
|
||||
export_date DATE NOT NULL,
|
||||
rows_total INT NOT NULL,
|
||||
rows_upserted INT NOT NULL,
|
||||
rows_deactivated INT NOT NULL,
|
||||
created_at TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW() NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->comment('idtf_sync_log', '_table', 'Journal des synchronisations IDTF (une ligne par run de la commande app:idtf:sync).');
|
||||
$this->comment('idtf_sync_log', 'id', 'Cle technique auto-incrementee.');
|
||||
$this->comment('idtf_sync_log', 'schema', "Mode de transport synchronise : 'road' ou 'water'.");
|
||||
$this->comment('idtf_sync_log', 'export_date', "Date d'export du fichier source traite par ce run.");
|
||||
$this->comment('idtf_sync_log', 'rows_total', 'Nombre de lignes exploitables lues dans le fichier.');
|
||||
$this->comment('idtf_sync_log', 'rows_upserted', 'Nombre de lignes inserees ou mises a jour.');
|
||||
$this->comment('idtf_sync_log', 'rows_deactivated', 'Nombre de lignes passees a is_active=false (absentes de cet export).');
|
||||
$this->comment('idtf_sync_log', 'created_at', 'Horodatage de fin du run (insertion du journal).');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS idtf_sync_log');
|
||||
$this->addSql('DROP TABLE IF EXISTS idtf_product');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pose un COMMENT ON TABLE/COLUMN en dollar-quoting Postgres ($_$...$_$)
|
||||
* pour eviter tout echappement d'apostrophes dans les descriptions.
|
||||
*/
|
||||
private function comment(string $table, string $column, string $description): void
|
||||
{
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* M4 — Repertoire transporteurs (ERP-155/157) : creation du schema BDD du
|
||||
* repertoire transporteurs sous le module Transport (jumeau des M2/M3).
|
||||
*
|
||||
* Tables creees :
|
||||
* - carrier : table principale (formulaire + lien QUALIMAT + archive + soft-delete
|
||||
* + Timestampable/Blamable) ;
|
||||
* - carrier_address / carrier_contact / carrier_price : sous-collections 1:n.
|
||||
*
|
||||
* Tables NON recrees (reutilisees) :
|
||||
* - qualimat_carrier (ERP-39, Version20260612150000) : cible de la FK editable
|
||||
* carrier.qualimat_carrier_id (§ 2.5) ;
|
||||
* - uploaded_document (ERP-154, Version20260615130000) : cible de la FK
|
||||
* carrier.discharge_document_id (Decharge, § 2.7) ;
|
||||
* - client / client_address / supplier / supplier_address (M1/M2) et site (Sites) :
|
||||
* cibles des FK de carrier_price (onglet Prix, RG-4.10/4.11).
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
|
||||
* FK cross-module (user, client, client_address, supplier, supplier_address, site,
|
||||
* qualimat_carrier, uploaded_document). Le tri par timestamp au sein du namespace
|
||||
* racine garantit l'ordre apres la creation de ces tables sur base vide.
|
||||
*
|
||||
* Decision IDs (spec § 2.2, tranchee a ce ticket) : carrier et ses sous-tables
|
||||
* utilisent `INT GENERATED BY DEFAULT AS IDENTITY` (homogeneite globale Starseed
|
||||
* M1/M2/M3, evite la friction bigint->string de l'ORM). Seule
|
||||
* carrier.qualimat_carrier_id est BIGINT pour matcher qualimat_carrier.id (existant).
|
||||
* Horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le TimestampableBlamableTrait mappe
|
||||
* `datetime_immutable`), pour que `schema:update --force` reste un no-op.
|
||||
*
|
||||
* Chaque colonne porte son `COMMENT ON COLUMN` (regle ABSOLUE n°12). Les 4
|
||||
* tables carrier* etant mappees par l'ORM des ce ticket, elles sont aussi ajoutees
|
||||
* a ColumnCommentsCatalog : `app:apply-column-comments` (test-db-setup) rejoue ces
|
||||
* COMMENT apres le `schema:update --force` qui les droperait sinon.
|
||||
*/
|
||||
final class Version20260615150000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-155/157 (M4) : tables carrier + carrier_address + carrier_contact + carrier_price (repertoire transporteurs).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->createCarrierTable();
|
||||
$this->createCarrierAddress();
|
||||
$this->createCarrierContact();
|
||||
$this->createCarrierPrice();
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Ordre inverse des dependances FK : sous-collections d'abord, puis carrier.
|
||||
$this->addSql('DROP TABLE IF EXISTS carrier_price');
|
||||
$this->addSql('DROP TABLE IF EXISTS carrier_contact');
|
||||
$this->addSql('DROP TABLE IF EXISTS carrier_address');
|
||||
$this->addSql('DROP TABLE IF EXISTS carrier');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Table principale `carrier`
|
||||
// =================================================================
|
||||
|
||||
private function createCarrierTable(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE carrier (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
qualimat_carrier_id BIGINT DEFAULT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
certification_type VARCHAR(20) DEFAULT NULL,
|
||||
is_chartered BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
indexation_rate NUMERIC(5, 2) DEFAULT NULL,
|
||||
container_type VARCHAR(12) DEFAULT NULL,
|
||||
volume_m3 NUMERIC(10, 2) DEFAULT NULL,
|
||||
discharge_document_id INT DEFAULT NULL,
|
||||
liot_plates TEXT DEFAULT NULL,
|
||||
is_archived BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_carrier_certification_type
|
||||
CHECK (certification_type IS NULL OR certification_type IN ('QUALIMAT', 'GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE')),
|
||||
CONSTRAINT chk_carrier_container_type
|
||||
CHECK (container_type IS NULL OR container_type IN ('BENNE', 'FOND_MOUVANT')),
|
||||
CONSTRAINT fk_carrier_qualimat
|
||||
FOREIGN KEY (qualimat_carrier_id) REFERENCES qualimat_carrier (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_carrier_discharge_document
|
||||
FOREIGN KEY (discharge_document_id) REFERENCES uploaded_document (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_carrier_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_carrier_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_carrier_is_archived ON carrier (is_archived)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_deleted_at ON carrier (deleted_at)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_qualimat ON carrier (qualimat_carrier_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_discharge_document ON carrier (discharge_document_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_created_by ON carrier (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_updated_by ON carrier (updated_by)');
|
||||
|
||||
// Unicite metier partielle : nom insensible a la casse, parmi les
|
||||
// non-archives ET non soft-deletes uniquement (§ 2.6). Inexprimable en ORM.
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE UNIQUE INDEX uq_carrier_name_active
|
||||
ON carrier (LOWER(name))
|
||||
WHERE is_archived = FALSE AND deleted_at IS NULL
|
||||
SQL);
|
||||
|
||||
$this->comment('carrier', '_table', 'Repertoire transporteurs (M4 Transport) — entites editables, archivables (is_archived) et soft-deletables (deleted_at). Distinct du referentiel qualimat_carrier.');
|
||||
$this->comment('carrier', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('carrier', 'qualimat_carrier_id', 'Lien editable vers le referentiel QUALIMAT (saisie assistee RG-4.01). FK -> qualimat_carrier.id, ON DELETE SET NULL : transporteur conserve si la ligne QUALIMAT disparait.');
|
||||
$this->comment('carrier', 'name', 'Raison sociale du transporteur (stockee en MAJUSCULES). Unique case-insensitive parmi les non-archives/non-supprimes (uq_carrier_name_active, RG-4.12 / § 2.6).');
|
||||
$this->comment('carrier', 'certification_type', 'Type de certification : QUALIMAT (si lie, lecture seule) ou GMP_PLUS/OVOCOM/COMPTE_PROPRE/AUTRE. AUTRE declenche le champ Decharge (RG-4.02). Null en cas LIOT (RG-4.01).');
|
||||
$this->comment('carrier', 'is_chartered', '« Affreter » coche : declenche indexation/benne-fond mouvant/volume, obligatoires (RG-4.03). Faux par defaut.');
|
||||
$this->comment('carrier', 'indexation_rate', 'Taux d indexation en pourcentage (NUMERIC 5,2) — renseigne si affrete (RG-4.03).');
|
||||
$this->comment('carrier', 'container_type', 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_container_type) — renseigne si affrete (RG-4.03).');
|
||||
$this->comment('carrier', 'volume_m3', 'Volume en m3 (NUMERIC 10,2) — renseigne si affrete (RG-4.03).');
|
||||
$this->comment('carrier', 'discharge_document_id', 'Document de Decharge (visible si certification_type = AUTRE, RG-4.02). FK -> uploaded_document.id (infra Shared § 2.7), ON DELETE SET NULL.');
|
||||
$this->comment('carrier', 'liot_plates', 'Immatriculations LIOT separees par « ; » (cas special nom=LIOT, RG-4.01). Les autres champs sont masques dans ce cas.');
|
||||
$this->comment('carrier', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission transport.carriers.archive (Admin seul).');
|
||||
$this->comment('carrier', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.');
|
||||
$this->comment('carrier', 'deleted_at', 'Horodatage du soft-delete technique — non expose par l API au M4. Null = ligne active.');
|
||||
$this->addTimestampableBlamableComments('carrier');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : adresses (1:n)
|
||||
// =================================================================
|
||||
|
||||
private function createCarrierAddress(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE carrier_address (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
carrier_id INT NOT NULL,
|
||||
country VARCHAR(80) DEFAULT 'France' NOT NULL,
|
||||
postal_code VARCHAR(20) DEFAULT NULL,
|
||||
city VARCHAR(120) DEFAULT NULL,
|
||||
street VARCHAR(255) DEFAULT NULL,
|
||||
street_complement VARCHAR(255) DEFAULT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_carrier_address_carrier
|
||||
FOREIGN KEY (carrier_id) REFERENCES carrier (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_carrier_address_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_carrier_address_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_carrier_address_carrier ON carrier_address (carrier_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_address_created_by ON carrier_address (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_address_updated_by ON carrier_address (updated_by)');
|
||||
|
||||
$this->comment('carrier_address', '_table', 'Adresses d un transporteur (1:n) — onglet Adresse (M4). Pre-remplie depuis QUALIMAT si applicable (RG-4.05).');
|
||||
$this->comment('carrier_address', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('carrier_address', 'carrier_id', 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire de l adresse.');
|
||||
$this->comment('carrier_address', 'country', 'Pays de l adresse — defaut France.');
|
||||
$this->comment('carrier_address', 'postal_code', 'Code postal (saisie assistee BAN cote front, RG-4.06).');
|
||||
$this->comment('carrier_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front.');
|
||||
$this->comment('carrier_address', 'street', 'Numero et voie de l adresse.');
|
||||
$this->comment('carrier_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.');
|
||||
$this->comment('carrier_address', 'position', 'Ordre d affichage de l adresse dans la liste du transporteur (croissant).');
|
||||
$this->addTimestampableBlamableComments('carrier_address');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : contacts (1:n)
|
||||
// =================================================================
|
||||
|
||||
private function createCarrierContact(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE carrier_contact (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
carrier_id INT NOT NULL,
|
||||
first_name VARCHAR(120) DEFAULT NULL,
|
||||
last_name VARCHAR(120) DEFAULT NULL,
|
||||
job_title VARCHAR(120) DEFAULT NULL,
|
||||
phone_primary VARCHAR(20) DEFAULT NULL,
|
||||
phone_secondary VARCHAR(20) DEFAULT NULL,
|
||||
email VARCHAR(180) DEFAULT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_carrier_contact_filled
|
||||
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL
|
||||
OR phone_primary IS NOT NULL OR email IS NOT NULL),
|
||||
CONSTRAINT fk_carrier_contact_carrier
|
||||
FOREIGN KEY (carrier_id) REFERENCES carrier (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_carrier_contact_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_carrier_contact_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_carrier_contact_carrier ON carrier_contact (carrier_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_contact_created_by ON carrier_contact (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_contact_updated_by ON carrier_contact (updated_by)');
|
||||
|
||||
$this->comment('carrier_contact', '_table', 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins un champ rempli (RG-4.08, chk_carrier_contact_filled), max 2 telephones.');
|
||||
$this->comment('carrier_contact', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('carrier_contact', 'carrier_id', 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.');
|
||||
$this->comment('carrier_contact', 'first_name', 'Prenom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).');
|
||||
$this->comment('carrier_contact', 'last_name', 'Nom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).');
|
||||
$this->comment('carrier_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).');
|
||||
$this->comment('carrier_contact', 'phone_primary', 'Telephone principal — chiffres uniquement (normalisation serveur).');
|
||||
$this->comment('carrier_contact', 'phone_secondary', 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).');
|
||||
$this->comment('carrier_contact', 'email', 'Email du contact (lowercase serveur).');
|
||||
$this->comment('carrier_contact', 'position', 'Ordre d affichage du contact dans la liste du transporteur (croissant).');
|
||||
$this->addTimestampableBlamableComments('carrier_contact');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : prix (1:n) — onglet Prix (RG-4.09 -> RG-4.11)
|
||||
// =================================================================
|
||||
|
||||
private function createCarrierPrice(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE carrier_price (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
carrier_id INT NOT NULL,
|
||||
direction VARCHAR(12) NOT NULL,
|
||||
client_id INT DEFAULT NULL,
|
||||
client_delivery_address_id INT DEFAULT NULL,
|
||||
departure_site_id INT DEFAULT NULL,
|
||||
supplier_id INT DEFAULT NULL,
|
||||
supplier_supply_address_id INT DEFAULT NULL,
|
||||
delivery_site_id INT DEFAULT NULL,
|
||||
container_type VARCHAR(12) NOT NULL,
|
||||
pricing_unit VARCHAR(8) NOT NULL,
|
||||
price NUMERIC(12, 2) NOT NULL,
|
||||
price_state VARCHAR(12) NOT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_carrier_price_direction CHECK (direction IN ('CLIENT', 'FOURNISSEUR')),
|
||||
CONSTRAINT chk_carrier_price_container CHECK (container_type IN ('BENNE', 'FOND_MOUVANT')),
|
||||
CONSTRAINT chk_carrier_price_unit CHECK (pricing_unit IN ('FORFAIT', 'TONNE')),
|
||||
CONSTRAINT chk_carrier_price_state CHECK (price_state IN ('EN_COURS', 'VALIDE', 'NON_VALIDE')),
|
||||
CONSTRAINT chk_carrier_price_client_branch
|
||||
CHECK (direction <> 'CLIENT' OR (client_id IS NOT NULL AND supplier_id IS NULL)),
|
||||
CONSTRAINT chk_carrier_price_supplier_branch
|
||||
CHECK (direction <> 'FOURNISSEUR' OR (supplier_id IS NOT NULL AND client_id IS NULL)),
|
||||
CONSTRAINT fk_carrier_price_carrier
|
||||
FOREIGN KEY (carrier_id) REFERENCES carrier (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_carrier_price_client
|
||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_carrier_price_client_address
|
||||
FOREIGN KEY (client_delivery_address_id) REFERENCES client_address (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_carrier_price_departure_site
|
||||
FOREIGN KEY (departure_site_id) REFERENCES site (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_carrier_price_supplier
|
||||
FOREIGN KEY (supplier_id) REFERENCES supplier (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_carrier_price_supplier_address
|
||||
FOREIGN KEY (supplier_supply_address_id) REFERENCES supplier_address (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_carrier_price_delivery_site
|
||||
FOREIGN KEY (delivery_site_id) REFERENCES site (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_carrier_price_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_carrier_price_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_carrier_price_carrier ON carrier_price (carrier_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_price_client ON carrier_price (client_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_price_client_address ON carrier_price (client_delivery_address_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_price_departure_site ON carrier_price (departure_site_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_price_supplier ON carrier_price (supplier_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_price_supplier_address ON carrier_price (supplier_supply_address_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_price_delivery_site ON carrier_price (delivery_site_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_price_created_by ON carrier_price (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_price_updated_by ON carrier_price (updated_by)');
|
||||
|
||||
$this->comment('carrier_price', '_table', 'Prix d un transporteur (1:n) — onglet Prix (M4). Branche CLIENT ou FOURNISSEUR selon direction (RG-4.09→4.11, CHECK chk_carrier_price_*).');
|
||||
$this->comment('carrier_price', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('carrier_price', 'carrier_id', 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du prix.');
|
||||
$this->comment('carrier_price', 'direction', 'Sens du prix : CLIENT ou FOURNISSEUR (RG-4.09). Pilote l affichage et l obligation des colonnes client_*/supplier_* (RG-4.10/4.11).');
|
||||
$this->comment('carrier_price', 'client_id', 'Branche CLIENT (RG-4.10) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi direction = CLIENT.');
|
||||
$this->comment('carrier_price', 'client_delivery_address_id', 'Branche CLIENT : adresse de livraison du client. FK -> client_address.id, ON DELETE RESTRICT.');
|
||||
$this->comment('carrier_price', 'departure_site_id', 'Branche CLIENT : adresse de depart = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.');
|
||||
$this->comment('carrier_price', 'supplier_id', 'Branche FOURNISSEUR (RG-4.11) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi direction = FOURNISSEUR.');
|
||||
$this->comment('carrier_price', 'supplier_supply_address_id', 'Branche FOURNISSEUR : adresse d approvisionnement du fournisseur. FK -> supplier_address.id, ON DELETE RESTRICT.');
|
||||
$this->comment('carrier_price', 'delivery_site_id', 'Branche FOURNISSEUR : adresse de livraison = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.');
|
||||
$this->comment('carrier_price', 'container_type', 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_price_container).');
|
||||
$this->comment('carrier_price', 'pricing_unit', 'Unite de tarification FORFAIT|TONNE (chk_carrier_price_unit).');
|
||||
$this->comment('carrier_price', 'price', 'Montant du prix (NUMERIC 12,2).');
|
||||
$this->comment('carrier_price', 'price_state', 'Etat du prix : EN_COURS, VALIDE ou NON_VALIDE (chk_carrier_price_state). Affiche dans le tableau Prix.');
|
||||
$this->comment('carrier_price', 'position', 'Ordre d affichage du prix dans la liste du transporteur (croissant).');
|
||||
$this->addTimestampableBlamableComments('carrier_price');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Helpers (identiques au M2 Version20260605130000)
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
|
||||
* en reutilisant le catalogue partage (source unique, ERP-67).
|
||||
*/
|
||||
private function addTimestampableBlamableComments(string $table): void
|
||||
{
|
||||
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
|
||||
$this->comment($table, $column, $description);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN`
|
||||
* en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement d apostrophe.
|
||||
*/
|
||||
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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\ClientInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
@@ -147,7 +148,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Index(name: 'idx_client_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_client_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class Client implements TimestampableInterface, BlamableInterface
|
||||
class Client implements TimestampableInterface, BlamableInterface, ClientInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepositor
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\ClientAddressInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
@@ -89,7 +90,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Table(name: 'client_address')]
|
||||
#[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])]
|
||||
#[Auditable]
|
||||
class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
class ClientAddress implements TimestampableInterface, BlamableInterface, ClientAddressInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SupplierInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
@@ -142,7 +143,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Index(name: 'idx_supplier_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_supplier_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class Supplier implements TimestampableInterface, BlamableInterface
|
||||
class Supplier implements TimestampableInterface, BlamableInterface, SupplierInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SupplierAddressInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -96,7 +97,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Table(name: 'supplier_address')]
|
||||
#[ORM\Index(name: 'idx_supplier_address_supplier', columns: ['supplier_id'])]
|
||||
#[Auditable]
|
||||
class SupplierAddress implements TimestampableInterface, BlamableInterface
|
||||
class SupplierAddress implements TimestampableInterface, BlamableInterface, SupplierAddressInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
@@ -117,7 +118,11 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['supplier:item:read'])]
|
||||
// supplier_address:read : groupe additif consomme par l'embed cross-module
|
||||
// (CarrierPrice.supplierSupplyAddress, M4 § 3.4). Inerte pour M2 (ses contextes
|
||||
// ne l'incluent pas) — expose le libelle d'adresse quand un autre module embarque
|
||||
// une SupplierAddress.
|
||||
#[Groups(['supplier:item:read', 'supplier_address:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Supplier::class, inversedBy: 'addresses')]
|
||||
@@ -130,12 +135,12 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\NotBlank(message: 'Le type d\'adresse est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Choice(choices: self::ADDRESS_TYPES, message: 'Le type d\'adresse doit être Prospect, Départ ou Rendu.')]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private ?string $addressType = null;
|
||||
|
||||
#[ORM\Column(length: 80, options: ['default' => 'France'])]
|
||||
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private string $country = 'France';
|
||||
|
||||
// RG-2.05 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur).
|
||||
@@ -143,24 +148,24 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\NotBlank(message: 'Le code postal est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private ?string $postalCode = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private ?string $streetComplement = null;
|
||||
|
||||
// Specifique fournisseur : nombre de bennes sur le site.
|
||||
|
||||
@@ -51,9 +51,9 @@ final class RbacSeeder
|
||||
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
|
||||
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
|
||||
* attacher (admin n'apparait pas car il bypass tout via isAdmin ;
|
||||
* `commercial.clients.archive`, `commercial.suppliers.archive` et
|
||||
* `technique.providers.archive` ne sont attaches a aucun role metier —
|
||||
* admin seul).
|
||||
* `commercial.clients.archive`, `commercial.suppliers.archive`,
|
||||
* `technique.providers.archive` et `transport.carriers.archive` ne sont
|
||||
* attaches a aucun role metier — admin seul).
|
||||
*
|
||||
* Cloisonnement par site des prestataires (M3 § 2.13) : la permission
|
||||
* `sites.bypass_scope` est attribuee par defaut a Bureau / Compta /
|
||||
@@ -77,6 +77,9 @@ final class RbacSeeder
|
||||
// Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite).
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
// Transporteurs (M4 § 5.2, ERP-153) : view + manage (PAS archive -> admin seul).
|
||||
'transport.carriers.view',
|
||||
'transport.carriers.manage',
|
||||
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
|
||||
'sites.bypass_scope',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
@@ -120,6 +123,9 @@ final class RbacSeeder
|
||||
// (onglet Comptabilite masque/filtre pour la Commerciale).
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
// Transporteurs (M4 § 5.2, ERP-153) : view seul (consultation « Tout »,
|
||||
// ni manage ni archive pour la Commerciale).
|
||||
'transport.carriers.view',
|
||||
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
|
||||
'sites.bypass_scope',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
|
||||
@@ -212,6 +212,11 @@ final class SeedE2ECommand extends Command
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
'technique.providers.archive',
|
||||
// Transport — Repertoire transporteurs (M4, ERP-153). Meme
|
||||
// logique : mappe sur le persona "tout". Miroir de personas.ts.
|
||||
'transport.carriers.view',
|
||||
'transport.carriers.manage',
|
||||
'transport.carriers.archive',
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
+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 /
|
||||
* nom / fonction / telephone principal / email est renseigne (double garde avec
|
||||
* le CHECK BDD chk_provider_contact_name — leve une 422 propre rattachee au
|
||||
* champ `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
|
||||
* chaines vides (y compris une fonction ou un phone_secondary vides) sont deja
|
||||
* ramenees a null et ne suffisent pas a valider le bloc.
|
||||
* RG-3.04 : un bloc Contact exige au moins le prenom OU le nom (aligne sur le
|
||||
* M1/M2 — un contact se materialise par son nom ; fonction / telephone / email
|
||||
* seuls ne suffisent pas). Double garde avec le CHECK BDD chk_provider_contact_name
|
||||
* — leve une 422 propre rattachee au champ `firstName` plutot qu'une 500 SQL.
|
||||
* Joue apres normalisation (les chaines vides sont deja ramenees a null).
|
||||
*/
|
||||
private function validateName(ProviderContact $contact): void
|
||||
{
|
||||
if (null === $contact->getFirstName()
|
||||
&& null === $contact->getLastName()
|
||||
&& null === $contact->getJobTitle()
|
||||
&& null === $contact->getPhonePrimary()
|
||||
&& null === $contact->getEmail()) {
|
||||
if (null === $contact->getFirstName() && null === $contact->getLastName()) {
|
||||
$violations = new ConstraintViolationList();
|
||||
$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,
|
||||
[],
|
||||
$contact,
|
||||
|
||||
@@ -9,6 +9,7 @@ use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
||||
use App\Module\Technique\Application\Validator\ProviderAccountingCompletenessValidator;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use DateTimeImmutable;
|
||||
@@ -75,6 +76,15 @@ final class ProviderProcessor implements ProcessorInterface
|
||||
'paymentType', 'bank',
|
||||
];
|
||||
|
||||
/**
|
||||
* Champs comptables obligatoires a la validation complete de l'onglet
|
||||
* (spec-front M3 § Onglet Comptabilite — miroir M1/M2). bank est exclu :
|
||||
* conditionnel (RG-3.07).
|
||||
*/
|
||||
private const array ACCOUNTING_REQUIRED_FIELDS = [
|
||||
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType',
|
||||
];
|
||||
|
||||
/** Champ d'archivage (groupe provider:write:archive). */
|
||||
private const string ARCHIVE_FIELD = 'isArchived';
|
||||
|
||||
@@ -102,6 +112,7 @@ final class ProviderProcessor implements ProcessorInterface
|
||||
private readonly Security $security,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ProviderAccountingCompletenessValidator $accountingValidator,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
@@ -128,6 +139,10 @@ final class ProviderProcessor implements ProcessorInterface
|
||||
// deux cotes (l'etat persiste l'a deja ete).
|
||||
$this->guardManage($data);
|
||||
|
||||
// Completude de l'onglet Comptabilite (apres normalize : les chaines vides
|
||||
// sont deja ramenees a null). Joue uniquement sur une soumission d'onglet.
|
||||
$this->validateAccountingCompleteness($data);
|
||||
|
||||
try {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
@@ -496,6 +511,21 @@ final class ProviderProcessor implements ProcessorInterface
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
/**
|
||||
* Completude de l'onglet Comptabilite (miroir SupplierProcessor) : ne se
|
||||
* declenche que si TOUS les champs requis sont presents dans le payload
|
||||
* (= soumission d'onglet, pas un PATCH partiel cible). Delegue au validateur
|
||||
* qui leve une 422 listant chaque champ manquant (mapping inline ERP-101).
|
||||
*/
|
||||
private function validateAccountingCompleteness(Provider $data): void
|
||||
{
|
||||
if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->accountingValidator->validate($data);
|
||||
}
|
||||
|
||||
private function payloadKeys(): array
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Application\Idtf;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
use function array_slice;
|
||||
|
||||
/**
|
||||
* Parsing pur d'une matrice (lignes/colonnes 0-indexees, telle que retournee
|
||||
* par PhpSpreadsheet::toArray) de l'export Excel IDTF vers des lignes
|
||||
* normalisees pretes a l'upsert. Sans dependance a PhpSpreadsheet : la matrice
|
||||
* est un simple tableau, ce qui rend le parsing testable en isolation.
|
||||
*
|
||||
* Robuste au reordonnancement des colonnes (mapping par libelle normalise) et
|
||||
* aux lignes de preambule (detection dynamique de la ligne d'en-tete). Voir
|
||||
* ERP-149 § 2.
|
||||
*/
|
||||
final class IdtfSheetParser
|
||||
{
|
||||
/**
|
||||
* @param array<int, array<int, mixed>> $matrix
|
||||
*
|
||||
* @return array{exportDate: null|string, rows: list<array<string, mixed>>}
|
||||
*/
|
||||
public static function parse(array $matrix): array
|
||||
{
|
||||
$exportDate = self::extractExportDate($matrix);
|
||||
$headerIndex = self::findHeaderIndex($matrix);
|
||||
|
||||
if (null === $headerIndex) {
|
||||
throw new RuntimeException("Ligne d'en-tete introuvable (colonne 'Numero IDTF').");
|
||||
}
|
||||
|
||||
$map = self::buildColumnMap($matrix[$headerIndex]);
|
||||
|
||||
if (!isset($map['idtf_number'])) {
|
||||
throw new RuntimeException("Colonne 'Numero IDTF' introuvable dans l'en-tete.");
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach (array_slice($matrix, $headerIndex + 1) as $row) {
|
||||
$idtf = trim((string) ($row[$map['idtf_number']] ?? ''));
|
||||
|
||||
// Ligne vide / non exploitable : pas d'identifiant numerique.
|
||||
if ('' === $idtf || !ctype_digit($idtf)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'idtf_number' => (int) $idtf,
|
||||
'product_group' => self::val($row, $map['product_group'] ?? null),
|
||||
'name' => self::val($row, $map['name'] ?? null) ?? '',
|
||||
'cleaning_regime' => self::val($row, $map['cleaning_regime'] ?? null) ?? '',
|
||||
'important_requirements' => self::val($row, $map['important_requirements'] ?? null),
|
||||
'mandatory_date' => self::parseDate(self::val($row, $map['mandatory_date'] ?? null)),
|
||||
'related_products' => self::val($row, $map['related_products'] ?? null),
|
||||
'formula' => self::val($row, $map['formula'] ?? null),
|
||||
'eural_code' => self::val($row, $map['eural_code'] ?? null),
|
||||
'cas_numbers' => self::splitCas(self::val($row, $map['cas'] ?? null)),
|
||||
'footnotes' => self::val($row, $map['footnotes'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
return ['exportDate' => $exportDate, 'rows' => $rows];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cherche une date "d-m-Y" dans les premieres lignes (preambule
|
||||
* "Export date: 12-6-2026") et la convertit en "Y-m-d". Null si absente.
|
||||
*
|
||||
* @param array<int, array<int, mixed>> $matrix
|
||||
*/
|
||||
public static function extractExportDate(array $matrix): ?string
|
||||
{
|
||||
foreach (array_slice($matrix, 0, 5) as $row) {
|
||||
$line = implode(' ', array_map(static fn (mixed $c): string => (string) $c, $row));
|
||||
|
||||
if (preg_match('/(\d{1,2})-(\d{1,2})-(\d{4})/', $line, $m)) {
|
||||
$day = (int) $m[1];
|
||||
$month = (int) $m[2];
|
||||
$year = (int) $m[3];
|
||||
|
||||
if (checkdate($month, $day, $year)) {
|
||||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index de la ligne d'en-tete : premiere ligne contenant une cellule dont
|
||||
* le libelle normalise contient "numero idtf".
|
||||
*
|
||||
* @param array<int, array<int, mixed>> $matrix
|
||||
*/
|
||||
private static function findHeaderIndex(array $matrix): ?int
|
||||
{
|
||||
foreach ($matrix as $i => $row) {
|
||||
foreach ($row as $cell) {
|
||||
if (str_contains(self::normalize((string) $cell), 'numero idtf')) {
|
||||
return $i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le mapping logique -> index de colonne a partir de la ligne
|
||||
* d'en-tete (resiste au reordonnancement via fields[]).
|
||||
*
|
||||
* @param array<int, mixed> $header
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private static function buildColumnMap(array $header): array
|
||||
{
|
||||
$map = [];
|
||||
|
||||
foreach ($header as $col => $label) {
|
||||
$n = self::normalize((string) $label);
|
||||
|
||||
$key = match (true) {
|
||||
str_contains($n, 'numero idtf') => 'idtf_number',
|
||||
str_contains($n, 'product group'),
|
||||
str_contains($n, 'groupe') => 'product_group',
|
||||
str_contains($n, 'nom de la marchandise') => 'name',
|
||||
str_contains($n, 'regime de nettoyage') => 'cleaning_regime',
|
||||
str_contains($n, 'exigences importantes') => 'important_requirements',
|
||||
str_contains($n, 'date d application') => 'mandatory_date',
|
||||
str_contains($n, 'produits apparentes') => 'related_products',
|
||||
str_contains($n, 'formule') => 'formula',
|
||||
str_contains($n, 'code eural') => 'eural_code',
|
||||
str_contains($n, 'numero cas') => 'cas',
|
||||
str_contains($n, 'annotations') => 'footnotes',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (null !== $key && !isset($map[$key])) {
|
||||
$map[$key] = (int) $col;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une date "dd-mm-yyyy" en "yyyy-mm-dd". Null si format invalide
|
||||
* ou date calendaire impossible.
|
||||
*/
|
||||
private static function parseDate(?string $raw): ?string
|
||||
{
|
||||
if (null === $raw || !preg_match('/^(\d{1,2})-(\d{1,2})-(\d{4})$/', $raw, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$day = (int) $m[1];
|
||||
$month = (int) $m[2];
|
||||
$year = (int) $m[3];
|
||||
|
||||
if (!checkdate($month, $day, $year)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eclate une cellule "Numero CAS" sur ';' en liste de chaines non vides.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function splitCas(?string $raw): array
|
||||
{
|
||||
if (null === $raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$parts = array_map('trim', explode(';', $raw));
|
||||
|
||||
return array_values(array_filter($parts, static fn (string $v): bool => '' !== $v));
|
||||
}
|
||||
|
||||
/**
|
||||
* Valeur d'une cellule par index : trim, null si absente/vide.
|
||||
*
|
||||
* @param array<int, mixed> $row
|
||||
*/
|
||||
private static function val(array $row, ?int $col): ?string
|
||||
{
|
||||
if (null === $col) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$v = trim((string) ($row[$col] ?? ''));
|
||||
|
||||
return '' === $v ? null : $v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un libelle d'en-tete : minuscules, sans accents ni apostrophes,
|
||||
* espaces compresses (pour un matching robuste).
|
||||
*/
|
||||
private static function normalize(string $s): string
|
||||
{
|
||||
$s = str_replace(['’', "'"], ' ', $s);
|
||||
$s = (string) iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
|
||||
$s = mb_strtolower($s);
|
||||
|
||||
return trim((string) preg_replace('/\s+/', ' ', $s));
|
||||
}
|
||||
}
|
||||
@@ -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,412 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\CarrierProvider;
|
||||
use App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Entity\UploadedDocument;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
|
||||
/**
|
||||
* Transporteur (M4 Transport) — entite racine du repertoire transporteurs,
|
||||
* jumelle de Supplier (M2) / Provider (M3). Porte le formulaire principal, le
|
||||
* lien editable vers le referentiel QUALIMAT (§ 2.5), l'archivage
|
||||
* (is_archived / archived_at) et le soft-delete technique prepare mais non
|
||||
* expose au M4 (deleted_at).
|
||||
*
|
||||
* Perimetre WT3 (ERP-155/157) = CONTRAT DE LECTURE uniquement : l'#[ApiResource]
|
||||
* n'expose que GetCollection + Get (via CarrierProvider). La creation /
|
||||
* modification (POST/PATCH + CarrierProcessor : normalisation, RG-4.01→4.14,
|
||||
* 409 doublon, gating archive) et les sous-ressources d'ecriture
|
||||
* (adresses/contacts/prix) arrivent aux worktrees suivants (WT4+). C'est
|
||||
* pourquoi les proprietes ne portent ICI que des read-groups (carrier:read /
|
||||
* carrier:item:read / qualimat:read), sans groupe d'ecriture ni contrainte
|
||||
* Assert de validation (qui appartiennent au flux d'ecriture). Les invariants
|
||||
* BDD (NOT NULL, CHECK enum, FK, unicite partielle) restent garantis par la
|
||||
* migration Version20260615150000.
|
||||
*
|
||||
* Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) :
|
||||
* - LISTE (carrier:read + qualimat:read + default:read) : name, certificationType,
|
||||
* qualimatCarrier (statut/validite — RG-4.04), updatedAt.
|
||||
* - DETAIL (+ carrier:item:read + embeds client/supplier/site...) : sous-collections
|
||||
* addresses / contacts / prices embarquees, avec les entites cross-module
|
||||
* (Client/Supplier/Site/adresses) serialisees via leurs read-groups.
|
||||
*
|
||||
* Pas de #[ORM\UniqueConstraint] : l'unicite du nom (RG-4.12) est portee par
|
||||
* l'index partiel fonctionnel uq_carrier_name_active (LOWER(name) WHERE
|
||||
* is_archived = FALSE AND deleted_at IS NULL), inexprimable en attribut ORM.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('transport.carriers.view')",
|
||||
// Liste : embarque qualimatCarrier (ManyToOne, fetch-join sur cette
|
||||
// seule relation cote repository — § 2.11) pour le statut/date de
|
||||
// validite QUALIMAT (RG-4.04). Aucune sous-collection en liste.
|
||||
normalizationContext: ['groups' => ['carrier:read', 'qualimat:read', 'default:read']],
|
||||
provider: CarrierProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('transport.carriers.view')",
|
||||
// Detail : transporteur + qualimatCarrier + sous-collections embarquees
|
||||
// (addresses / contacts / prices). Les relations cross-module des prix
|
||||
// (client / supplier / sites / adresses) sont embarquees via leurs
|
||||
// read-groups (client:read / supplier:read / ... — bugs #1/#2 M1).
|
||||
normalizationContext: ['groups' => [
|
||||
'carrier:read',
|
||||
'carrier:item:read',
|
||||
'qualimat:read',
|
||||
'client:read',
|
||||
'client_address:read',
|
||||
'supplier:read',
|
||||
'supplier_address:read',
|
||||
'site:read',
|
||||
'default:read',
|
||||
]],
|
||||
provider: CarrierProvider::class,
|
||||
),
|
||||
// Pas de Post/Patch/Delete au WT3 (lecture seule). Ecriture + archivage : WT4.
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineCarrierRepository::class)]
|
||||
#[ORM\Table(name: 'carrier')]
|
||||
#[ORM\Index(name: 'idx_carrier_is_archived', columns: ['is_archived'])]
|
||||
#[ORM\Index(name: 'idx_carrier_deleted_at', columns: ['deleted_at'])]
|
||||
#[ORM\Index(name: 'idx_carrier_qualimat', columns: ['qualimat_carrier_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_discharge_document', columns: ['discharge_document_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_carrier_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class Carrier implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['carrier:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['carrier:read'])]
|
||||
private ?string $name = null;
|
||||
|
||||
/** Lien editable vers le referentiel QUALIMAT (saisie assistee RG-4.01, § 2.5). */
|
||||
#[ORM\ManyToOne(targetEntity: QualimatCarrier::class)]
|
||||
#[ORM\JoinColumn(name: 'qualimat_carrier_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['carrier:read'])]
|
||||
private ?QualimatCarrier $qualimatCarrier = null;
|
||||
|
||||
/** QUALIMAT|GMP_PLUS|OVOCOM|COMPTE_PROPRE|AUTRE ; null en cas LIOT (RG-4.01). */
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
#[Groups(['carrier:read'])]
|
||||
private ?string $certificationType = null;
|
||||
|
||||
#[ORM\Column(name: 'is_chartered', options: ['default' => false])]
|
||||
private bool $isChartered = false;
|
||||
|
||||
/** % d'indexation — renseigne si affrete (RG-4.03). */
|
||||
#[ORM\Column(name: 'indexation_rate', type: 'decimal', precision: 5, scale: 2, nullable: true)]
|
||||
#[Groups(['carrier:read'])]
|
||||
private ?string $indexationRate = null;
|
||||
|
||||
/** BENNE|FOND_MOUVANT — renseigne si affrete (RG-4.03). */
|
||||
#[ORM\Column(name: 'container_type', length: 12, nullable: true)]
|
||||
#[Groups(['carrier:read'])]
|
||||
private ?string $containerType = null;
|
||||
|
||||
/** Volume m3 — renseigne si affrete (RG-4.03). */
|
||||
#[ORM\Column(name: 'volume_m3', type: 'decimal', precision: 10, scale: 2, nullable: true)]
|
||||
#[Groups(['carrier:read'])]
|
||||
private ?string $volumeM3 = null;
|
||||
|
||||
/** Decharge (upload, visible si certificationType = AUTRE — RG-4.02). Infra Shared (§ 2.7). */
|
||||
#[ORM\ManyToOne(targetEntity: UploadedDocument::class)]
|
||||
#[ORM\JoinColumn(name: 'discharge_document_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['carrier:read'])]
|
||||
private ?UploadedDocument $dischargeDocument = null;
|
||||
|
||||
/** Immatriculations LIOT separees par « ; » (cas LIOT — RG-4.01). */
|
||||
#[ORM\Column(name: 'liot_plates', type: 'text', nullable: true)]
|
||||
#[Groups(['carrier:read'])]
|
||||
private ?string $liotPlates = null;
|
||||
|
||||
// === Sous-collections — EMBARQUEES dans le DETAIL (read-group sur le getter) ===
|
||||
/** @var Collection<int, CarrierAddress> */
|
||||
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $addresses;
|
||||
|
||||
/** @var Collection<int, CarrierContact> */
|
||||
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $contacts;
|
||||
|
||||
/** @var Collection<int, CarrierPrice> */
|
||||
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierPrice::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $prices;
|
||||
|
||||
// === Archive / Soft delete ===
|
||||
#[ORM\Column(name: 'is_archived', options: ['default' => false])]
|
||||
private bool $isArchived = false;
|
||||
|
||||
#[ORM\Column(name: 'archived_at', type: 'datetime_immutable', nullable: true)]
|
||||
#[Groups(['carrier:read'])]
|
||||
private ?DateTimeImmutable $archivedAt = null;
|
||||
|
||||
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
|
||||
private ?DateTimeImmutable $deletedAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->addresses = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
$this->prices = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getQualimatCarrier(): ?QualimatCarrier
|
||||
{
|
||||
return $this->qualimatCarrier;
|
||||
}
|
||||
|
||||
public function setQualimatCarrier(?QualimatCarrier $qualimatCarrier): static
|
||||
{
|
||||
$this->qualimatCarrier = $qualimatCarrier;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCertificationType(): ?string
|
||||
{
|
||||
return $this->certificationType;
|
||||
}
|
||||
|
||||
public function setCertificationType(?string $certificationType): static
|
||||
{
|
||||
$this->certificationType = $certificationType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// Boolean trap (RETEX M1 bug #3) : #[Groups] + #[SerializedName] sur le getter,
|
||||
// sinon Symfony strip le prefixe "is" et drope la cle du JSON.
|
||||
#[Groups(['carrier:read'])]
|
||||
#[SerializedName('isChartered')]
|
||||
public function isChartered(): bool
|
||||
{
|
||||
return $this->isChartered;
|
||||
}
|
||||
|
||||
public function setIsChartered(bool $isChartered): static
|
||||
{
|
||||
$this->isChartered = $isChartered;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIndexationRate(): ?string
|
||||
{
|
||||
return $this->indexationRate;
|
||||
}
|
||||
|
||||
public function setIndexationRate(?string $indexationRate): static
|
||||
{
|
||||
$this->indexationRate = $indexationRate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContainerType(): ?string
|
||||
{
|
||||
return $this->containerType;
|
||||
}
|
||||
|
||||
public function setContainerType(?string $containerType): static
|
||||
{
|
||||
$this->containerType = $containerType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVolumeM3(): ?string
|
||||
{
|
||||
return $this->volumeM3;
|
||||
}
|
||||
|
||||
public function setVolumeM3(?string $volumeM3): static
|
||||
{
|
||||
$this->volumeM3 = $volumeM3;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDischargeDocument(): ?UploadedDocument
|
||||
{
|
||||
return $this->dischargeDocument;
|
||||
}
|
||||
|
||||
public function setDischargeDocument(?UploadedDocument $dischargeDocument): static
|
||||
{
|
||||
$this->dischargeDocument = $dischargeDocument;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLiotPlates(): ?string
|
||||
{
|
||||
return $this->liotPlates;
|
||||
}
|
||||
|
||||
public function setLiotPlates(?string $liotPlates): static
|
||||
{
|
||||
$this->liotPlates = $liotPlates;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CarrierAddress> */
|
||||
#[Groups(['carrier:item:read'])]
|
||||
public function getAddresses(): Collection
|
||||
{
|
||||
return $this->addresses;
|
||||
}
|
||||
|
||||
public function addAddress(CarrierAddress $address): static
|
||||
{
|
||||
if (!$this->addresses->contains($address)) {
|
||||
$this->addresses->add($address);
|
||||
$address->setCarrier($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeAddress(CarrierAddress $address): static
|
||||
{
|
||||
if ($this->addresses->removeElement($address) && $address->getCarrier() === $this) {
|
||||
$address->setCarrier(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CarrierContact> */
|
||||
#[Groups(['carrier:item:read'])]
|
||||
public function getContacts(): Collection
|
||||
{
|
||||
return $this->contacts;
|
||||
}
|
||||
|
||||
public function addContact(CarrierContact $contact): static
|
||||
{
|
||||
if (!$this->contacts->contains($contact)) {
|
||||
$this->contacts->add($contact);
|
||||
$contact->setCarrier($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeContact(CarrierContact $contact): static
|
||||
{
|
||||
if ($this->contacts->removeElement($contact) && $contact->getCarrier() === $this) {
|
||||
$contact->setCarrier(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CarrierPrice> */
|
||||
#[Groups(['carrier:item:read'])]
|
||||
public function getPrices(): Collection
|
||||
{
|
||||
return $this->prices;
|
||||
}
|
||||
|
||||
public function addPrice(CarrierPrice $price): static
|
||||
{
|
||||
if (!$this->prices->contains($price)) {
|
||||
$this->prices->add($price);
|
||||
$price->setCarrier($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removePrice(CarrierPrice $price): static
|
||||
{
|
||||
if ($this->prices->removeElement($price) && $price->getCarrier() === $this) {
|
||||
$price->setCarrier(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// Boolean trap (cf. isChartered) : groupe de lecture + SerializedName sur le getter.
|
||||
#[Groups(['carrier:read'])]
|
||||
#[SerializedName('isArchived')]
|
||||
public function isArchived(): bool
|
||||
{
|
||||
return $this->isArchived;
|
||||
}
|
||||
|
||||
public function setIsArchived(bool $isArchived): static
|
||||
{
|
||||
$this->isArchived = $isArchived;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getArchivedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->archivedAt;
|
||||
}
|
||||
|
||||
public function setArchivedAt(?DateTimeImmutable $archivedAt): static
|
||||
{
|
||||
$this->archivedAt = $archivedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->deletedAt;
|
||||
}
|
||||
|
||||
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
|
||||
{
|
||||
$this->deletedAt = $deletedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Entity;
|
||||
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Adresse d'un transporteur (1:n) — onglet Adresse (M4). Jumelle de
|
||||
* SupplierAddress (M2), version simplifiee (pas de type d'adresse, pas de M2M
|
||||
* sites/categories sur l'adresse : les sites du M4 vivent dans l'onglet Prix).
|
||||
*
|
||||
* WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read`
|
||||
* (embarquees au detail du transporteur). Les sous-ressources d'ecriture
|
||||
* (POST/PATCH/DELETE) + RG-4.05→4.07 arrivent au worktree dedie (WT6).
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'carrier_address')]
|
||||
#[ORM\Index(name: 'idx_carrier_address_carrier', columns: ['carrier_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_address_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_carrier_address_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class CarrierAddress implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'addresses')]
|
||||
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Carrier $carrier = null;
|
||||
|
||||
#[ORM\Column(length: 80, options: ['default' => 'France'])]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private string $country = 'France';
|
||||
|
||||
#[ORM\Column(name: 'postal_code', length: 20, nullable: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $postalCode = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(name: 'street_complement', length: 255, nullable: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $streetComplement = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCarrier(): ?Carrier
|
||||
{
|
||||
return $this->carrier;
|
||||
}
|
||||
|
||||
public function setCarrier(?Carrier $carrier): static
|
||||
{
|
||||
$this->carrier = $carrier;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCountry(): string
|
||||
{
|
||||
return $this->country;
|
||||
}
|
||||
|
||||
public function setCountry(string $country): static
|
||||
{
|
||||
$this->country = $country;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPostalCode(): ?string
|
||||
{
|
||||
return $this->postalCode;
|
||||
}
|
||||
|
||||
public function setPostalCode(?string $postalCode): static
|
||||
{
|
||||
$this->postalCode = $postalCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCity(): ?string
|
||||
{
|
||||
return $this->city;
|
||||
}
|
||||
|
||||
public function setCity(?string $city): static
|
||||
{
|
||||
$this->city = $city;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStreet(): ?string
|
||||
{
|
||||
return $this->street;
|
||||
}
|
||||
|
||||
public function setStreet(?string $street): static
|
||||
{
|
||||
$this->street = $street;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStreetComplement(): ?string
|
||||
{
|
||||
return $this->streetComplement;
|
||||
}
|
||||
|
||||
public function setStreetComplement(?string $streetComplement): static
|
||||
{
|
||||
$this->streetComplement = $streetComplement;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Entity;
|
||||
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Contact d'un transporteur (1:n) — onglet Contact (M4). Jumeau de
|
||||
* SupplierContact (M2) : au moins un champ rempli (RG-4.08, garanti par le
|
||||
* CHECK chk_carrier_contact_filled + le Processor), max 2 telephones.
|
||||
*
|
||||
* WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read`
|
||||
* (embarquees au detail). Les sous-ressources d'ecriture arrivent au WT7.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'carrier_contact')]
|
||||
#[ORM\Index(name: 'idx_carrier_contact_carrier', columns: ['carrier_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_contact_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_carrier_contact_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class CarrierContact implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'contacts')]
|
||||
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Carrier $carrier = null;
|
||||
|
||||
#[ORM\Column(name: 'first_name', length: 120, nullable: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(name: 'last_name', length: 120, nullable: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(name: 'job_title', length: 120, nullable: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $jobTitle = null;
|
||||
|
||||
#[ORM\Column(name: 'phone_primary', length: 20, nullable: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $phonePrimary = null;
|
||||
|
||||
#[ORM\Column(name: 'phone_secondary', length: 20, nullable: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $phoneSecondary = null;
|
||||
|
||||
#[ORM\Column(length: 180, nullable: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCarrier(): ?Carrier
|
||||
{
|
||||
return $this->carrier;
|
||||
}
|
||||
|
||||
public function setCarrier(?Carrier $carrier): static
|
||||
{
|
||||
$this->carrier = $carrier;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFirstName(): ?string
|
||||
{
|
||||
return $this->firstName;
|
||||
}
|
||||
|
||||
public function setFirstName(?string $firstName): static
|
||||
{
|
||||
$this->firstName = $firstName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastName(): ?string
|
||||
{
|
||||
return $this->lastName;
|
||||
}
|
||||
|
||||
public function setLastName(?string $lastName): static
|
||||
{
|
||||
$this->lastName = $lastName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getJobTitle(): ?string
|
||||
{
|
||||
return $this->jobTitle;
|
||||
}
|
||||
|
||||
public function setJobTitle(?string $jobTitle): static
|
||||
{
|
||||
$this->jobTitle = $jobTitle;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhonePrimary(): ?string
|
||||
{
|
||||
return $this->phonePrimary;
|
||||
}
|
||||
|
||||
public function setPhonePrimary(?string $phonePrimary): static
|
||||
{
|
||||
$this->phonePrimary = $phonePrimary;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhoneSecondary(): ?string
|
||||
{
|
||||
return $this->phoneSecondary;
|
||||
}
|
||||
|
||||
public function setPhoneSecondary(?string $phoneSecondary): static
|
||||
{
|
||||
$this->phoneSecondary = $phoneSecondary;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function setEmail(?string $email): static
|
||||
{
|
||||
$this->email = $email;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Entity;
|
||||
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\ClientAddressInterface;
|
||||
use App\Shared\Domain\Contract\ClientInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SupplierAddressInterface;
|
||||
use App\Shared\Domain\Contract\SupplierInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Prix d'un transporteur (1:n) — onglet Prix (M4, RG-4.09→4.11). Une ligne porte
|
||||
* soit une branche CLIENT (client + adresse de livraison + site de depart), soit
|
||||
* une branche FOURNISSEUR (supplier + adresse d'appro + site de livraison),
|
||||
* selon `direction`. La coherence des branches est garantie en BDD par les CHECK
|
||||
* chk_carrier_price_client_branch / chk_carrier_price_supplier_branch.
|
||||
*
|
||||
* Relations cross-module (Client/Supplier/adresses M1-M2, Site Sites) referencees
|
||||
* via des contrats Shared (ClientInterface, SupplierInterface, ...) + resolve_target_entities
|
||||
* — JAMAIS d'import direct d'une entite d'un autre module (regle ABSOLUE n°1).
|
||||
* L'embed JSON au detail passe par les read-groups des entites concretes
|
||||
* (client:read / client_address:read / supplier:read / supplier_address:read /
|
||||
* site:read), inclus dans le contexte du Get racine de Carrier (§ 4.0).
|
||||
*
|
||||
* WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read`. Les
|
||||
* sous-ressources d'ecriture + validation des branches (Processor) : WT8.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'carrier_price')]
|
||||
#[ORM\Index(name: 'idx_carrier_price_carrier', columns: ['carrier_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_price_client', columns: ['client_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_price_client_address', columns: ['client_delivery_address_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_price_departure_site', columns: ['departure_site_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_price_supplier', columns: ['supplier_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_price_supplier_address', columns: ['supplier_supply_address_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_price_delivery_site', columns: ['delivery_site_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_price_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_carrier_price_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class CarrierPrice implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'prices')]
|
||||
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Carrier $carrier = null;
|
||||
|
||||
/** CLIENT|FOURNISSEUR (RG-4.09) — pilote la branche active. */
|
||||
#[ORM\Column(length: 12)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $direction = null;
|
||||
|
||||
// === Branche CLIENT (RG-4.10) ===
|
||||
#[ORM\ManyToOne(targetEntity: ClientInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?ClientInterface $client = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ClientAddressInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'client_delivery_address_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?ClientAddressInterface $clientDeliveryAddress = null;
|
||||
|
||||
/** Adresse de depart = un des 3 sites (86/17/82). */
|
||||
#[ORM\ManyToOne(targetEntity: SiteInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'departure_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?SiteInterface $departureSite = null;
|
||||
|
||||
// === Branche FOURNISSEUR (RG-4.11) ===
|
||||
#[ORM\ManyToOne(targetEntity: SupplierInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'supplier_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?SupplierInterface $supplier = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: SupplierAddressInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'supplier_supply_address_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?SupplierAddressInterface $supplierSupplyAddress = null;
|
||||
|
||||
/** Adresse de livraison = un des 3 sites (86/17/82). */
|
||||
#[ORM\ManyToOne(targetEntity: SiteInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'delivery_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?SiteInterface $deliverySite = null;
|
||||
|
||||
// === Commun ===
|
||||
/** BENNE|FOND_MOUVANT. */
|
||||
#[ORM\Column(name: 'container_type', length: 12)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $containerType = null;
|
||||
|
||||
/** FORFAIT|TONNE. */
|
||||
#[ORM\Column(name: 'pricing_unit', length: 8)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $pricingUnit = null;
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 12, scale: 2)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $price = null;
|
||||
|
||||
/** EN_COURS|VALIDE|NON_VALIDE. */
|
||||
#[ORM\Column(name: 'price_state', length: 12)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $priceState = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCarrier(): ?Carrier
|
||||
{
|
||||
return $this->carrier;
|
||||
}
|
||||
|
||||
public function setCarrier(?Carrier $carrier): static
|
||||
{
|
||||
$this->carrier = $carrier;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDirection(): ?string
|
||||
{
|
||||
return $this->direction;
|
||||
}
|
||||
|
||||
public function setDirection(?string $direction): static
|
||||
{
|
||||
$this->direction = $direction;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getClient(): ?ClientInterface
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function setClient(?ClientInterface $client): static
|
||||
{
|
||||
$this->client = $client;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getClientDeliveryAddress(): ?ClientAddressInterface
|
||||
{
|
||||
return $this->clientDeliveryAddress;
|
||||
}
|
||||
|
||||
public function setClientDeliveryAddress(?ClientAddressInterface $clientDeliveryAddress): static
|
||||
{
|
||||
$this->clientDeliveryAddress = $clientDeliveryAddress;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDepartureSite(): ?SiteInterface
|
||||
{
|
||||
return $this->departureSite;
|
||||
}
|
||||
|
||||
public function setDepartureSite(?SiteInterface $departureSite): static
|
||||
{
|
||||
$this->departureSite = $departureSite;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSupplier(): ?SupplierInterface
|
||||
{
|
||||
return $this->supplier;
|
||||
}
|
||||
|
||||
public function setSupplier(?SupplierInterface $supplier): static
|
||||
{
|
||||
$this->supplier = $supplier;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSupplierSupplyAddress(): ?SupplierAddressInterface
|
||||
{
|
||||
return $this->supplierSupplyAddress;
|
||||
}
|
||||
|
||||
public function setSupplierSupplyAddress(?SupplierAddressInterface $supplierSupplyAddress): static
|
||||
{
|
||||
$this->supplierSupplyAddress = $supplierSupplyAddress;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeliverySite(): ?SiteInterface
|
||||
{
|
||||
return $this->deliverySite;
|
||||
}
|
||||
|
||||
public function setDeliverySite(?SiteInterface $deliverySite): static
|
||||
{
|
||||
$this->deliverySite = $deliverySite;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContainerType(): ?string
|
||||
{
|
||||
return $this->containerType;
|
||||
}
|
||||
|
||||
public function setContainerType(?string $containerType): static
|
||||
{
|
||||
$this->containerType = $containerType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPricingUnit(): ?string
|
||||
{
|
||||
return $this->pricingUnit;
|
||||
}
|
||||
|
||||
public function setPricingUnit(?string $pricingUnit): static
|
||||
{
|
||||
$this->pricingUnit = $pricingUnit;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPrice(): ?string
|
||||
{
|
||||
return $this->price;
|
||||
}
|
||||
|
||||
public function setPrice(?string $price): static
|
||||
{
|
||||
$this->price = $price;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPriceState(): ?string
|
||||
{
|
||||
return $this->priceState;
|
||||
}
|
||||
|
||||
public function setPriceState(?string $priceState): static
|
||||
{
|
||||
$this->priceState = $priceState;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
|
||||
/**
|
||||
* Mapping ORM LECTURE SEULE sur la table existante `qualimat_carrier`
|
||||
* (referentiel des transporteurs agrees QUALIMAT, ERP-39). La table est
|
||||
* alimentee/soft-deletee EXCLUSIVEMENT par la commande console `app:qualimat:sync` ;
|
||||
* cette entite n'expose donc AUCUNE ecriture (ni Post/Patch/Delete).
|
||||
*
|
||||
* Role M4 (ERP-155/157) :
|
||||
* - cible de la FK editable `carrier.qualimat_carrier_id` (§ 2.5) ;
|
||||
* - embarquee (groupe `qualimat:read`) dans la liste et le detail Carrier pour
|
||||
* afficher statut + date de validite QUALIMAT (RG-4.04) ;
|
||||
* - endpoint de recherche `GET /api/qualimat_carriers?...` pour la saisie
|
||||
* assistee du nom (§ 4.7) — filtres built-in name/siret (partiel), isActive.
|
||||
*
|
||||
* La table reste hors `schema_filter` Doctrine (doctrine.yaml) : c'est la
|
||||
* migration modulaire Version20260612150000 qui possede son DDL et ses COMMENT
|
||||
* (pas l'ORM). Lecture seule + referentiel synchronise => exclue de
|
||||
* EntitiesAreTimestampableBlamableTest et non #[Auditable].
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('transport.carriers.view')",
|
||||
normalizationContext: ['groups' => ['qualimat:read', 'default:read']],
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('transport.carriers.view')",
|
||||
normalizationContext: ['groups' => ['qualimat:read', 'default:read']],
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'siret' => 'partial'])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['isActive'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name'], arguments: ['orderParameterName' => 'order'])]
|
||||
#[ORM\Entity]
|
||||
// Mapping reproduisant a l'identique le DDL de la migration ERP-39
|
||||
// (Version20260612150000) pour que `schema:update --force` reste un no-op :
|
||||
// contrainte d'unicite siret + index is_active.
|
||||
#[ORM\Table(name: 'qualimat_carrier')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_qualimat_carrier_siret', columns: ['siret'])]
|
||||
#[ORM\Index(name: 'idx_qualimat_carrier_active', columns: ['is_active'])]
|
||||
class QualimatCarrier
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
#[ORM\Column(type: 'bigint')]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?string $siret = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?string $address = null;
|
||||
|
||||
#[ORM\Column(name: 'postal_code', length: 10, nullable: true)]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?string $postalCode = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 32, nullable: true)]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?string $phone = null;
|
||||
|
||||
#[ORM\Column(length: 64, nullable: true)]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?string $department = null;
|
||||
|
||||
#[ORM\Column(length: 32)]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?string $status = null;
|
||||
|
||||
#[ORM\Column(name: 'validity_date', type: 'date_immutable', nullable: true)]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?DateTimeImmutable $validityDate = null;
|
||||
|
||||
#[ORM\Column(name: 'is_active', options: ['default' => true])]
|
||||
#[Groups(['qualimat:read'])]
|
||||
#[SerializedName('isActive')]
|
||||
private bool $isActive = true;
|
||||
|
||||
// Colonne technique de synchro (soft-delete) — mappee pour completude, non
|
||||
// serialisee. Alimentee par app:qualimat:sync. columnDefinition pin la
|
||||
// precision TIMESTAMP(6) du DDL ERP-39 pour eviter un ALTER de schema:update
|
||||
// (le datetime_immutable par defaut mapperait sur TIMESTAMP(0)).
|
||||
#[ORM\Column(name: 'last_synced_at', type: 'datetime_immutable', columnDefinition: 'TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL')]
|
||||
private ?DateTimeImmutable $lastSyncedAt = null;
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getSiret(): ?string
|
||||
{
|
||||
return $this->siret;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getAddress(): ?string
|
||||
{
|
||||
return $this->address;
|
||||
}
|
||||
|
||||
public function getPostalCode(): ?string
|
||||
{
|
||||
return $this->postalCode;
|
||||
}
|
||||
|
||||
public function getCity(): ?string
|
||||
{
|
||||
return $this->city;
|
||||
}
|
||||
|
||||
public function getPhone(): ?string
|
||||
{
|
||||
return $this->phone;
|
||||
}
|
||||
|
||||
public function getDepartment(): ?string
|
||||
{
|
||||
return $this->department;
|
||||
}
|
||||
|
||||
public function getStatus(): ?string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function getValidityDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->validityDate;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->isActive;
|
||||
}
|
||||
|
||||
public function getLastSyncedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->lastSyncedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Repository;
|
||||
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
* Contrat du repository transporteurs (M4). Implementation Doctrine :
|
||||
* App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository.
|
||||
*/
|
||||
interface CarrierRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Carrier;
|
||||
|
||||
public function save(Carrier $carrier): void;
|
||||
|
||||
/**
|
||||
* QueryBuilder de SELECTION (filtres + tri) pour la liste. Exclut les
|
||||
* soft-deletes (deleted_at IS NOT NULL) et, par defaut, les archives.
|
||||
* Fetch-join uniquement qualimatCarrier (ManyToOne, sur — § 2.11) : la liste
|
||||
* n'embarque aucune sous-collection. Tri par defaut name ASC.
|
||||
*
|
||||
* @param list<string> $certificationTypes filtre repetable (OR) sur certificationType
|
||||
*/
|
||||
public function createListQueryBuilder(
|
||||
bool $includeArchived = false,
|
||||
?string $search = null,
|
||||
array $certificationTypes = [],
|
||||
): QueryBuilder;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Repository\CarrierRepositoryInterface;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider du repertoire transporteurs (M4, spec-back § 4.1 / § 4.2). Jumeau du
|
||||
* SupplierProvider (M2), simplifie : pas de cloisonnement par site (§ 2.3) et
|
||||
* aucune sous-collection a hydrater en liste (le contrat liste n'embarque que
|
||||
* qualimatCarrier, deja fetch-joine par le repository — § 2.11).
|
||||
*
|
||||
* Collection (GET /api/carriers) :
|
||||
* - exclut par defaut les archives (is_archived = true) ET les soft-deletes ;
|
||||
* - ?includeArchived=true reintegre les archives (soft-deletes toujours exclus) ;
|
||||
* - filtres ?search= (fuzzy name) et ?certificationType= (repetable) ;
|
||||
* - tri par defaut name ASC ; pagination Hydra (regle n°13) + echappatoire
|
||||
* ?pagination=false.
|
||||
*
|
||||
* Item (GET /api/carriers/{id}) : 404 si introuvable OU soft-delete. Les archives
|
||||
* restent consultables en detail.
|
||||
*
|
||||
* @implements ProviderInterface<Carrier>
|
||||
*/
|
||||
final class CarrierProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository')]
|
||||
private readonly CarrierRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Carrier|iterable|Paginator|null
|
||||
{
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
return $this->provideCollection($operation, $context);
|
||||
}
|
||||
|
||||
return $this->provideItem($uriVariables);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*
|
||||
* @return list<Carrier>|Paginator<Carrier>
|
||||
*/
|
||||
private function provideCollection(Operation $operation, array $context): array|Paginator
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
||||
$search = $filters['search'] ?? null;
|
||||
$certificationTypes = $this->readStringList($filters['certificationType'] ?? []);
|
||||
|
||||
$qb = $this->repository->createListQueryBuilder(
|
||||
$includeArchived,
|
||||
is_string($search) ? $search : null,
|
||||
$certificationTypes,
|
||||
);
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete (selects front).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
/** @var list<Carrier> $carriers */
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
// fetchJoinCollection: false — la seule jointure est un ManyToOne (sur),
|
||||
// pas une to-many : pas de besoin du mode collection du Paginator.
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $uriVariables
|
||||
*/
|
||||
private function provideItem(array $uriVariables): ?Carrier
|
||||
{
|
||||
$id = $uriVariables['id'] ?? null;
|
||||
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$carrier = $this->repository->findById((int) $id);
|
||||
if (null === $carrier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Soft-delete : jamais expose (404). Les archives restent consultables.
|
||||
if (null !== $carrier->getDeletedAt()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $carrier;
|
||||
}
|
||||
|
||||
private function readBool(mixed $raw): bool
|
||||
{
|
||||
if (is_bool($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste de chaines (valeur unique ou ?key[]=a&key[]=b).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function readStringList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_string($value) && '' !== trim($value)) {
|
||||
$out[] = trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\Console;
|
||||
|
||||
use App\Module\Transport\Application\Idtf\IdtfSheetParser;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Throwable;
|
||||
|
||||
use function array_slice;
|
||||
use function count;
|
||||
use function in_array;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
use const JSON_UNESCAPED_UNICODE;
|
||||
|
||||
/**
|
||||
* ERP-149 : synchronise le referentiel des codes IDTF (regimes de nettoyage
|
||||
* transport).
|
||||
*
|
||||
* Recupere l'export Excel depuis le generateur icrt-idtf.com (ou un fichier
|
||||
* local), le parse et synchronise `idtf_product` de facon transactionnelle :
|
||||
* upsert sur (schema, idtf_number), soft-delete des absents, journal dans
|
||||
* `idtf_sync_log`. Idempotente (refresh complet).
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:idtf:sync',
|
||||
description: 'Synchronise le referentiel des codes IDTF depuis l\'export Excel icrt-idtf.com (upsert + soft-delete + journal).',
|
||||
)]
|
||||
final class SyncIdtfCommand extends Command
|
||||
{
|
||||
private const string GENERATOR_URL = 'https://www.icrt-idtf.com/fr/excel-generator/';
|
||||
|
||||
/**
|
||||
* Champs a cocher explicitement : `fields[]=all` ne deplie PAS les colonnes
|
||||
* cote serveur (6 colonnes seulement). Cette liste donne l'export complet
|
||||
* (11 colonnes). Cf. ERP-149 § 1.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const array EXPORT_FIELDS = [
|
||||
'product_number_idtf',
|
||||
'product_name',
|
||||
'minimum_cleaning_regime',
|
||||
'important_requirements',
|
||||
'date_mandatory',
|
||||
'related_products',
|
||||
'formula',
|
||||
'product_number_eural',
|
||||
'product_number_cas',
|
||||
'footnotes',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('schema', null, InputOption::VALUE_REQUIRED, "Module IDTF : 'road' (routier) ou 'water' (fluvial).", 'road')
|
||||
->addOption('file', null, InputOption::VALUE_REQUIRED, "Chemin d'un .xlsx local (court-circuite le telechargement, utile pour tests/rejeu).")
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Analyse sans ecriture en base.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$schema = (string) $input->getOption('schema');
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
$file = $input->getOption('file');
|
||||
|
||||
if (!in_array($schema, ['road', 'water'], true)) {
|
||||
$io->error("--schema doit valoir 'road' ou 'water'.");
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
// 1. Recuperation du binaire xlsx (local ou via POST).
|
||||
try {
|
||||
$xlsx = null !== $file ? $this->readLocal((string) $file) : $this->downloadExport($schema);
|
||||
} catch (Throwable $e) {
|
||||
$io->error('Telechargement/lecture impossible : '.$e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// 2. Parsing (xlsx -> matrice -> lignes normalisees).
|
||||
try {
|
||||
$parsed = IdtfSheetParser::parse($this->toMatrix($xlsx));
|
||||
} catch (Throwable $e) {
|
||||
$io->error('Parsing impossible : '.$e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$rows = $parsed['rows'];
|
||||
$exportDate = $parsed['exportDate'] ?? new DateTimeImmutable()->format('Y-m-d');
|
||||
|
||||
$io->section(sprintf('IDTF %s — export du %s', mb_strtoupper($schema), $exportDate));
|
||||
$io->writeln(sprintf('%d lignes exploitables lues.', count($rows)));
|
||||
|
||||
if ($dryRun) {
|
||||
$this->renderPreview($io, $rows);
|
||||
$io->note(sprintf('Dry-run : aucune ecriture. (%d lignes au total)', count($rows)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// 3. Sync transactionnelle : upsert -> soft-delete -> journal.
|
||||
$run = new DateTimeImmutable()->format('Y-m-d H:i:s.u');
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
try {
|
||||
$upserted = $this->upsertAll($schema, $exportDate, $rows, $run);
|
||||
$deactivated = $this->deactivateMissing($schema, $run);
|
||||
$this->log($schema, $exportDate, count($rows), $upserted, $deactivated);
|
||||
$this->connection->commit();
|
||||
} catch (Throwable $e) {
|
||||
$this->connection->rollBack();
|
||||
$io->error('Sync annulee (rollback) : '.$e->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d upsert, %d desactive(s).', $upserted, $deactivated));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejoue le POST du generateur pour recuperer le binaire xlsx complet.
|
||||
* Le formulaire poste sur lui-meme ; pas besoin de GET/cookies prealables.
|
||||
*/
|
||||
private function downloadExport(string $schema): string
|
||||
{
|
||||
// Corps construit a la main : http-client encoderait fields[] en
|
||||
// indices numerotes, on veut bien des "fields[]=..." repetes.
|
||||
$pairs = [
|
||||
'schema='.$schema,
|
||||
'type%5B%5D='.$schema,
|
||||
'roadRegime%5B%5D=all',
|
||||
'waterRegime%5B%5D=all',
|
||||
'groups%5B%5D=all',
|
||||
'products%5B%5D=all',
|
||||
];
|
||||
|
||||
foreach (self::EXPORT_FIELDS as $field) {
|
||||
$pairs[] = 'fields%5B%5D='.$field;
|
||||
}
|
||||
|
||||
$pairs[] = 'generateExcel=';
|
||||
|
||||
$response = $this->httpClient->request('POST', self::GENERATOR_URL, [
|
||||
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
|
||||
'body' => implode('&', $pairs),
|
||||
'timeout' => 90,
|
||||
]);
|
||||
|
||||
$content = $response->getContent();
|
||||
$contentType = $response->getHeaders(false)['content-type'][0] ?? '';
|
||||
|
||||
// Garde-fou : un HTML signifie un POST rejete (filtres/payload).
|
||||
if (!str_contains($contentType, 'spreadsheet') && !str_starts_with($content, "PK\x03\x04")) {
|
||||
throw new RuntimeException(sprintf('Reponse non-xlsx (content-type: %s). Verifie le payload.', $contentType));
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function readLocal(string $path): string
|
||||
{
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if (false === $raw) {
|
||||
throw new RuntimeException(sprintf('Fichier illisible : %s', $path));
|
||||
}
|
||||
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge le binaire xlsx via PhpSpreadsheet et retourne la feuille active
|
||||
* sous forme de matrice 0-indexee (lignes/colonnes).
|
||||
*
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
private function toMatrix(string $xlsx): array
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'idtf_').'.xlsx';
|
||||
file_put_contents($tmp, $xlsx);
|
||||
|
||||
try {
|
||||
// toArray(null, true, true, false) : colonnes 0-indexees.
|
||||
return IOFactory::load($tmp)->getActiveSheet()->toArray(null, true, true, false);
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert de toutes les lignes (cle naturelle = schema + idtf_number).
|
||||
*
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function upsertAll(string $schema, string $exportDate, array $rows, string $run): int
|
||||
{
|
||||
$sql = <<<'SQL'
|
||||
INSERT INTO idtf_product
|
||||
(idtf_number, schema, product_group, name, cleaning_regime, important_requirements,
|
||||
mandatory_date, related_products, formula, eural_code, cas_numbers, footnotes,
|
||||
source_export_date, is_active, last_synced_at)
|
||||
VALUES
|
||||
(:idtf, :schema, :grp, :name, :regime, :req, :mdate, :related, :formula, :eural,
|
||||
CAST(:cas AS JSONB), :foot, :export, TRUE, :run)
|
||||
ON CONFLICT (schema, idtf_number) DO UPDATE SET
|
||||
product_group = EXCLUDED.product_group,
|
||||
name = EXCLUDED.name,
|
||||
cleaning_regime = EXCLUDED.cleaning_regime,
|
||||
important_requirements = EXCLUDED.important_requirements,
|
||||
mandatory_date = EXCLUDED.mandatory_date,
|
||||
related_products = EXCLUDED.related_products,
|
||||
formula = EXCLUDED.formula,
|
||||
eural_code = EXCLUDED.eural_code,
|
||||
cas_numbers = EXCLUDED.cas_numbers,
|
||||
footnotes = EXCLUDED.footnotes,
|
||||
source_export_date = EXCLUDED.source_export_date,
|
||||
is_active = TRUE,
|
||||
last_synced_at = EXCLUDED.last_synced_at
|
||||
SQL;
|
||||
|
||||
$count = 0;
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$this->connection->executeStatement($sql, [
|
||||
'idtf' => $r['idtf_number'],
|
||||
'schema' => $schema,
|
||||
'grp' => $r['product_group'],
|
||||
'name' => $r['name'],
|
||||
'regime' => $r['cleaning_regime'],
|
||||
'req' => $r['important_requirements'],
|
||||
'mdate' => $r['mandatory_date'],
|
||||
'related' => $r['related_products'],
|
||||
'formula' => $r['formula'],
|
||||
'eural' => $r['eural_code'],
|
||||
'cas' => json_encode($r['cas_numbers'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'foot' => $r['footnotes'],
|
||||
'export' => $exportDate,
|
||||
'run' => $run,
|
||||
]);
|
||||
++$count;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete : toute ligne du schema active non revue par ce run passe a
|
||||
* is_active=false.
|
||||
*/
|
||||
private function deactivateMissing(string $schema, string $run): int
|
||||
{
|
||||
return (int) $this->connection->executeStatement(
|
||||
'UPDATE idtf_product SET is_active = FALSE WHERE schema = :schema AND is_active = TRUE AND last_synced_at < :run',
|
||||
['schema' => $schema, 'run' => $run],
|
||||
);
|
||||
}
|
||||
|
||||
private function log(string $schema, string $exportDate, int $total, int $upserted, int $deactivated): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
<<<'SQL'
|
||||
INSERT INTO idtf_sync_log (schema, export_date, rows_total, rows_upserted, rows_deactivated)
|
||||
VALUES (:schema, :export, :total, :upserted, :deactivated)
|
||||
SQL,
|
||||
[
|
||||
'schema' => $schema,
|
||||
'export' => $exportDate,
|
||||
'total' => $total,
|
||||
'upserted' => $upserted,
|
||||
'deactivated' => $deactivated,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function renderPreview(SymfonyStyle $io, array $rows): void
|
||||
{
|
||||
$io->table(
|
||||
['IDTF', 'Nom', 'Regime', 'CAS'],
|
||||
array_map(static fn (array $r): array => [
|
||||
(string) $r['idtf_number'],
|
||||
mb_strimwidth((string) $r['name'], 0, 50, '…'),
|
||||
(string) $r['cleaning_regime'],
|
||||
implode(', ', $r['cas_numbers']),
|
||||
], array_slice($rows, 0, 15)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Entity\CarrierContact;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
/**
|
||||
* Fixtures dev/test MINIMALES du repertoire transporteurs (M4, ERP-155/157) :
|
||||
* 2 transporteurs de demonstration suffisant a faire tourner les ecrans de
|
||||
* lecture (liste + detail). Les fixtures completes (cas QUALIMAT, affrete,
|
||||
* LIOT, prix CLIENT/FOURNISSEUR...) sont livrees par le worktree dedie (WT10) —
|
||||
* ne pas les developper ici (scope WT3 : contrat de lecture).
|
||||
*
|
||||
* Aucune dependance cross-module (pas de prix, pas de lien QUALIMAT) : la
|
||||
* fixture reste autonome et joue en fin de chaine sans contrainte d'ordre.
|
||||
*/
|
||||
final class CarrierFixtures extends Fixture
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// Transporteur certifie « classique ».
|
||||
$alpha = new Carrier();
|
||||
$alpha->setName('TRANSPORTS ALPHA');
|
||||
$alpha->setCertificationType('GMP_PLUS');
|
||||
$manager->persist($alpha);
|
||||
|
||||
$contact = new CarrierContact();
|
||||
$contact->setCarrier($alpha);
|
||||
$contact->setLastName('Durand');
|
||||
$contact->setPhonePrimary('0612345678');
|
||||
$alpha->addContact($contact);
|
||||
$manager->persist($contact);
|
||||
|
||||
// Transporteur affrete (RG-4.03).
|
||||
$beta = new Carrier();
|
||||
$beta->setName('TRANSPORTS BETA');
|
||||
$beta->setCertificationType('AUTRE');
|
||||
$beta->setIsChartered(true);
|
||||
$beta->setIndexationRate('5.00');
|
||||
$beta->setContainerType('BENNE');
|
||||
$beta->setVolumeM3('90.00');
|
||||
$manager->persist($beta);
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Repository\CarrierRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Carrier>
|
||||
*/
|
||||
class DoctrineCarrierRepository extends ServiceEntityRepository implements CarrierRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Carrier::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Carrier
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function save(Carrier $carrier): void
|
||||
{
|
||||
$this->getEntityManager()->persist($carrier);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function createListQueryBuilder(
|
||||
bool $includeArchived = false,
|
||||
?string $search = null,
|
||||
array $certificationTypes = [],
|
||||
): QueryBuilder {
|
||||
// Fetch-join de la SEULE relation ManyToOne qualimatCarrier (sur, pas de
|
||||
// cartesien) pour exposer statut/date de validite QUALIMAT en liste sans
|
||||
// N+1 (§ 2.11). Aucune sous-collection (addresses/contacts/prices) jointe
|
||||
// en liste : elles ne sont embarquees qu'au detail (carrier:item:read).
|
||||
$qb = $this->createQueryBuilder('c')
|
||||
->leftJoin('c.qualimatCarrier', 'q')->addSelect('q')
|
||||
->andWhere('c.deletedAt IS NULL')
|
||||
->orderBy('c.name', 'ASC')
|
||||
;
|
||||
|
||||
// Pas de cloisonnement par site (§ 2.3) : referentiel global.
|
||||
if (!$includeArchived) {
|
||||
$qb->andWhere('c.isArchived = false');
|
||||
}
|
||||
|
||||
$this->applySearch($qb, $search);
|
||||
$this->applyCertificationTypes($qb, $certificationTypes);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche fuzzy insensible a la casse sur le nom du transporteur (§ 4.1).
|
||||
* Metacaracteres LIKE (%, _, \) echappes pour rester litteraux.
|
||||
*/
|
||||
private function applySearch(QueryBuilder $qb, ?string $search): void
|
||||
{
|
||||
if (null === $search || '' === trim($search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||
|
||||
$qb->andWhere('LOWER(c.name) LIKE :search')
|
||||
->setParameter('search', $pattern)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restreint aux transporteurs dont la certification figure dans la liste (OR).
|
||||
* Alimente le filtre « Certification » de la liste (§ 4.1).
|
||||
*
|
||||
* @param list<string> $certificationTypes
|
||||
*/
|
||||
private function applyCertificationTypes(QueryBuilder $qb, array $certificationTypes): void
|
||||
{
|
||||
$codes = [];
|
||||
foreach ($certificationTypes as $code) {
|
||||
if (is_string($code) && '' !== trim($code)) {
|
||||
$codes[] = trim($code);
|
||||
}
|
||||
}
|
||||
|
||||
if ([] === $codes) {
|
||||
return;
|
||||
}
|
||||
|
||||
$qb->andWhere('c.certificationType IN (:certificationTypes)')
|
||||
->setParameter('certificationTypes', $codes)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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,34 @@
|
||||
<?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.
|
||||
*
|
||||
* Socle du repertoire transporteurs (M4 § 5.1, ERP-153) :
|
||||
* - `view` : consultation de la liste / fiche transporteur ;
|
||||
* - `manage` : creation / modification (hors archivage) ;
|
||||
* - `archive` : archivage / restauration (admin seul, cf. matrice § 5.2).
|
||||
*
|
||||
* Consommee par `app:sync-permissions`. Matrice role -> permissions dans
|
||||
* `RbacSeeder::MATRIX` (§ 5.2).
|
||||
*
|
||||
* @return array<int, array{code: string, label: string}>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'transport.carriers.view', 'label' => 'Voir les transporteurs'],
|
||||
['code' => 'transport.carriers.manage', 'label' => 'Créer / modifier les transporteurs'],
|
||||
['code' => 'transport.carriers.archive', 'label' => 'Archiver / restaurer un transporteur'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Contrat minimal d'une adresse de Client (M1 Commercial) exposable a un autre
|
||||
* module sans couplage direct (regle ABSOLUE n°1). Mappe vers
|
||||
* App\Module\Commercial\Domain\Entity\ClientAddress via `resolve_target_entities`.
|
||||
*
|
||||
* Implemente par App\Module\Commercial\Domain\Entity\ClientAddress. Utilise
|
||||
* comme type-hint des relations ORM cross-module (ex: CarrierPrice.clientDeliveryAddress,
|
||||
* M4). La serialisation passe par le read-group de l'entite concrete
|
||||
* (client_address:read), pas par cette interface.
|
||||
*/
|
||||
interface ClientAddressInterface
|
||||
{
|
||||
public function getId(): ?int;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Contrat minimal exposant ce qu'un autre module doit connaitre d'un Client
|
||||
* (M1 Commercial) sans creer de couplage direct vers le module Commercial
|
||||
* (regle ABSOLUE n°1). Mappe vers App\Module\Commercial\Domain\Entity\Client
|
||||
* via `resolve_target_entities` (doctrine.yaml).
|
||||
*
|
||||
* Implemente par App\Module\Commercial\Domain\Entity\Client. Utilise comme
|
||||
* type-hint dans les relations ORM cross-module (ex: CarrierPrice.client, M4).
|
||||
* La serialisation passe par les read-groups de l'entite concrete (client:read),
|
||||
* pas par cette interface.
|
||||
*/
|
||||
interface ClientInterface
|
||||
{
|
||||
public function getId(): ?int;
|
||||
|
||||
public function getCompanyName(): ?string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Contrat minimal d'une adresse de Supplier (M2 Commercial) exposable a un autre
|
||||
* module sans couplage direct (regle ABSOLUE n°1). Mappe vers
|
||||
* App\Module\Commercial\Domain\Entity\SupplierAddress via `resolve_target_entities`.
|
||||
*
|
||||
* Implemente par App\Module\Commercial\Domain\Entity\SupplierAddress. Utilise
|
||||
* comme type-hint des relations ORM cross-module (ex: CarrierPrice.supplierSupplyAddress,
|
||||
* M4). La serialisation passe par le read-group de l'entite concrete
|
||||
* (supplier_address:read), pas par cette interface.
|
||||
*/
|
||||
interface SupplierAddressInterface
|
||||
{
|
||||
public function getId(): ?int;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Contrat minimal exposant ce qu'un autre module doit connaitre d'un Supplier
|
||||
* (M2 Commercial) sans creer de couplage direct vers le module Commercial
|
||||
* (regle ABSOLUE n°1). Mappe vers App\Module\Commercial\Domain\Entity\Supplier
|
||||
* via `resolve_target_entities` (doctrine.yaml).
|
||||
*
|
||||
* Implemente par App\Module\Commercial\Domain\Entity\Supplier. Utilise comme
|
||||
* type-hint dans les relations ORM cross-module (ex: CarrierPrice.supplier, M4).
|
||||
* La serialisation passe par les read-groups de l'entite concrete (supplier:read),
|
||||
* pas par cette interface.
|
||||
*/
|
||||
interface SupplierInterface
|
||||
{
|
||||
public function getId(): ?int;
|
||||
|
||||
public function getCompanyName(): ?string;
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
return [
|
||||
'uploaded_document' => [
|
||||
'_table' => 'Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'original_filename' => 'Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.',
|
||||
'stored_path' => 'Chemin relatif du fichier sous var/uploads (ex: 2026/06/<hash>.pdf) — nom genere aleatoirement, jamais le nom client.',
|
||||
'mime_type' => 'Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).',
|
||||
'size_bytes' => 'Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.',
|
||||
'checksum' => 'Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).',
|
||||
'created_at' => 'Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).',
|
||||
'created_by' => 'ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.',
|
||||
],
|
||||
|
||||
'audit_log' => [
|
||||
'_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.",
|
||||
'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).",
|
||||
@@ -395,12 +407,12 @@ final class ColumnCommentsCatalog
|
||||
],
|
||||
|
||||
'provider_contact' => [
|
||||
'_table' => 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/telephone/email (RG-3.04, chk_provider_contact_name).',
|
||||
'_table' => 'Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.',
|
||||
'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).',
|
||||
'last_name' => 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).',
|
||||
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
|
||||
'first_name' => 'Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).',
|
||||
'last_name' => 'Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).',
|
||||
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).',
|
||||
'phone_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).',
|
||||
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).',
|
||||
'email' => 'Email du contact (lowercase serveur).',
|
||||
@@ -446,6 +458,86 @@ final class ColumnCommentsCatalog
|
||||
'iban' => 'IBAN du compte (≤ 34 caracteres).',
|
||||
'position' => 'Ordre d affichage du RIB dans la liste du prestataire (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
// === M4 Transport — referentiel QUALIMAT (ERP-39, mappe lecture seule des ERP-155) ===
|
||||
// Mappe par l'entite QualimatCarrier depuis M4 -> retire du schema_filter,
|
||||
// donc ses COMMENT sont rejoues par app:apply-column-comments apres schema:update.
|
||||
'qualimat_carrier' => [
|
||||
'_table' => "Referentiel des transporteurs agrees QUALIMAT, synchronise quotidiennement depuis l'API qualimat.org (type=operateur_transport).",
|
||||
'id' => 'Cle technique auto-incrementee.',
|
||||
'siret' => 'SIRET normalise (chiffres sans espaces). Cle naturelle de synchro (unique). Source parfois incomplete (longueur variable), non contrainte a 14.',
|
||||
'name' => 'Raison sociale du transporteur (champs Nom = Societe de la source, identiques).',
|
||||
'address' => 'Adresse postale (voie). Nullable.',
|
||||
'postal_code' => 'Code postal. Nullable.',
|
||||
'city' => 'Ville. Nullable.',
|
||||
'phone' => 'Telephone au format source "indicatif|numero" (ex: +33|0608890316). Nullable.',
|
||||
'department' => 'Departement au format source "code - libelle" (ex: 65 - Hautes-Pyrenees). Nullable.',
|
||||
'status' => "Statut d'agrement QUALIMAT (valeurs connues : Audite, Valide, Suspendu). Valeur brute de la source, non contrainte.",
|
||||
'validity_date' => 'Date de fin de validite de la certification (convertie depuis dd/mm/yyyy). Nullable.',
|
||||
'is_active' => 'Faux = transporteur absent du dernier import (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.',
|
||||
'last_synced_at' => 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).',
|
||||
],
|
||||
|
||||
// === M4 Transport — repertoire transporteurs (ERP-155/157) ===
|
||||
'carrier' => [
|
||||
'_table' => 'Repertoire transporteurs (M4 Transport) — entites editables, archivables (is_archived) et soft-deletables (deleted_at). Distinct du referentiel qualimat_carrier.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'qualimat_carrier_id' => 'Lien editable vers le referentiel QUALIMAT (saisie assistee RG-4.01). FK -> qualimat_carrier.id, ON DELETE SET NULL : transporteur conserve si la ligne QUALIMAT disparait.',
|
||||
'name' => 'Raison sociale du transporteur (stockee en MAJUSCULES). Unique case-insensitive parmi les non-archives/non-supprimes (uq_carrier_name_active, RG-4.12 / § 2.6).',
|
||||
'certification_type' => 'Type de certification : QUALIMAT (si lie, lecture seule) ou GMP_PLUS/OVOCOM/COMPTE_PROPRE/AUTRE. AUTRE declenche le champ Decharge (RG-4.02). Null en cas LIOT (RG-4.01).',
|
||||
'is_chartered' => '« Affreter » coche : declenche indexation/benne-fond mouvant/volume, obligatoires (RG-4.03). Faux par defaut.',
|
||||
'indexation_rate' => 'Taux d indexation en pourcentage (NUMERIC 5,2) — renseigne si affrete (RG-4.03).',
|
||||
'container_type' => 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_container_type) — renseigne si affrete (RG-4.03).',
|
||||
'volume_m3' => 'Volume en m3 (NUMERIC 10,2) — renseigne si affrete (RG-4.03).',
|
||||
'discharge_document_id' => 'Document de Decharge (visible si certification_type = AUTRE, RG-4.02). FK -> uploaded_document.id (infra Shared § 2.7), ON DELETE SET NULL.',
|
||||
'liot_plates' => 'Immatriculations LIOT separees par « ; » (cas special nom=LIOT, RG-4.01). Les autres champs sont masques dans ce cas.',
|
||||
'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission transport.carriers.archive (Admin seul).',
|
||||
'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.',
|
||||
'deleted_at' => 'Horodatage du soft-delete technique — non expose par l API au M4. Null = ligne active.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'carrier_address' => [
|
||||
'_table' => 'Adresses d un transporteur (1:n) — onglet Adresse (M4). Pre-remplie depuis QUALIMAT si applicable (RG-4.05).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire de l adresse.',
|
||||
'country' => 'Pays de l adresse — defaut France.',
|
||||
'postal_code' => 'Code postal (saisie assistee BAN cote front, RG-4.06).',
|
||||
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front.',
|
||||
'street' => 'Numero et voie de l adresse.',
|
||||
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
|
||||
'position' => 'Ordre d affichage de l adresse dans la liste du transporteur (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'carrier_contact' => [
|
||||
'_table' => 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins un champ rempli (RG-4.08, chk_carrier_contact_filled), max 2 telephones.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.',
|
||||
'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).',
|
||||
'last_name' => 'Nom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).',
|
||||
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
|
||||
'phone_primary' => 'Telephone principal — chiffres uniquement (normalisation serveur).',
|
||||
'phone_secondary' => 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).',
|
||||
'email' => 'Email du contact (lowercase serveur).',
|
||||
'position' => 'Ordre d affichage du contact dans la liste du transporteur (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'carrier_price' => [
|
||||
'_table' => 'Prix d un transporteur (1:n) — onglet Prix (M4). Branche CLIENT ou FOURNISSEUR selon direction (RG-4.09->4.11, CHECK chk_carrier_price_*).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du prix.',
|
||||
'direction' => 'Sens du prix : CLIENT ou FOURNISSEUR (RG-4.09). Pilote l affichage et l obligation des colonnes client_*/supplier_* (RG-4.10/4.11).',
|
||||
'client_id' => 'Branche CLIENT (RG-4.10) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi direction = CLIENT.',
|
||||
'client_delivery_address_id' => 'Branche CLIENT : adresse de livraison du client. FK -> client_address.id, ON DELETE RESTRICT.',
|
||||
'departure_site_id' => 'Branche CLIENT : adresse de depart = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.',
|
||||
'supplier_id' => 'Branche FOURNISSEUR (RG-4.11) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi direction = FOURNISSEUR.',
|
||||
'supplier_supply_address_id' => 'Branche FOURNISSEUR : adresse d approvisionnement du fournisseur. FK -> supplier_address.id, ON DELETE RESTRICT.',
|
||||
'delivery_site_id' => 'Branche FOURNISSEUR : adresse de livraison = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.',
|
||||
'container_type' => 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_price_container).',
|
||||
'pricing_unit' => 'Unite de tarification FORFAIT|TONNE (chk_carrier_price_unit).',
|
||||
'price' => 'Montant du prix (NUMERIC 12,2).',
|
||||
'price_state' => 'Etat du prix : EN_COURS, VALIDE ou NON_VALIDE (chk_carrier_price_state). Affiche dans le tableau Prix.',
|
||||
'position' => 'Ordre d affichage du prix dans la liste du transporteur (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use App\Module\Core\Domain\Entity\Permission;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Module\Transport\Domain\Entity\QualimatCarrier;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use Doctrine\ORM\Mapping\Entity;
|
||||
@@ -61,6 +62,10 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
||||
* spec-back M1 § 2.6 + § 3.5.
|
||||
* - Country (ERP-116) : referentiel statique des pays (id/code/name/position),
|
||||
* seede par migration, lecture seule. Meme justification que Bank.
|
||||
* - QualimatCarrier (M4, ERP-39/155) : mapping ORM LECTURE SEULE sur la table
|
||||
* referentielle qualimat_carrier, alimentee/soft-deletee exclusivement par
|
||||
* la commande `app:qualimat:sync` (pas de tracabilite user-driven, pas
|
||||
* d'ecriture API). Meme justification que les referentiels ci-dessus.
|
||||
*
|
||||
* Les futurs referentiels statiques s'ajoutent ici avec une justification.
|
||||
*/
|
||||
@@ -75,6 +80,7 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
||||
PaymentType::class,
|
||||
Bank::class,
|
||||
Country::class,
|
||||
QualimatCarrier::class,
|
||||
];
|
||||
|
||||
public function testAllBusinessEntitiesImplementBothInterfaces(): void
|
||||
|
||||
@@ -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
|
||||
* Comptabilite (groupe provider:write:accounting). On asserte le code HTTP et le
|
||||
* propertyPath de la violation (consommable par extractApiViolations cote front,
|
||||
* ERP-101). Jumeau de SupplierAccountingApiTest (M2), sans le bloc « completude de
|
||||
* l'onglet » : le prestataire est minimal et n'impose pas les six scalaires
|
||||
* comptables (spec M3 § 3.1).
|
||||
* ERP-101). Jumeau de SupplierAccountingApiTest (M2), completude de l'onglet
|
||||
* INCLUSE : a la validation complete de l'onglet, les six scalaires comptables
|
||||
* sont obligatoires (spec-front M3 § Onglet Comptabilite — aligne M1/M2).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
@@ -81,5 +81,58 @@ final class ProviderAccountingValidationTest extends AbstractProviderApiTestCase
|
||||
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.
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use App\Module\Technique\Domain\Entity\Provider;
|
||||
/**
|
||||
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire
|
||||
* (M3, spec § 4.5 — ERP-135). Couvrent : normalisation contact (RG-3.11), RG-3.04
|
||||
* (au moins 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),
|
||||
* 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
|
||||
@@ -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
|
||||
* prenom / nom / FONCTION / telephone / email (spec § RG-3.04, ligne 926). Ici
|
||||
* seul jobTitle (Fonction) est fourni -> le bloc est valide -> 201.
|
||||
* RG-3.04 (aligne M1/M2) : un bloc Contact exige le prenom OU le nom. Une
|
||||
* Fonction seule (sans nom ni prenom) ne suffit plus -> 422 rattachee a firstName.
|
||||
*/
|
||||
public function testPostContactWithOnlyJobTitleReturns201(): void
|
||||
public function testPostContactWithOnlyJobTitleReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$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', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['jobTitle' => ' '],
|
||||
'json' => ['jobTitle' => 'Directeur'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.04 : un bloc Contact sans prenom NI nom (meme avec d'autres champs ou
|
||||
* apres normalisation des chaines vides) est rejete avant la base -> 422
|
||||
* rattachee a firstName (double garde CHECK chk_provider_contact_name).
|
||||
*/
|
||||
public function testPostContactWithoutNameReturns422OnFirstNamePath(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Contact No Name');
|
||||
|
||||
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
// Email + telephone fournis mais ni prenom ni nom -> invalide (RG-3.04).
|
||||
'json' => ['email' => 'contact@acme.fr', 'phonePrimary' => '0612345678'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.04 : le prenom SEUL (sans nom) suffit a valider le contact -> 201.
|
||||
*/
|
||||
public function testPostContactWithOnlyFirstNameReturns201(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Contact FirstName Only');
|
||||
|
||||
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['firstName' => 'Jean'],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame('Jean', $data['firstName']);
|
||||
}
|
||||
|
||||
public function testPostContactOnMissingProviderReturns404(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use App\Module\Commercial\Domain\Entity\SupplierAddress;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Entity\CarrierAddress;
|
||||
use App\Module\Transport\Domain\Entity\CarrierContact;
|
||||
use App\Module\Transport\Domain\Entity\CarrierPrice;
|
||||
use App\Module\Transport\Domain\Entity\QualimatCarrier;
|
||||
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Base des tests fonctionnels du repertoire transporteurs (M4). Apporte les
|
||||
* factories de seed direct (sans passer par l'API : le flux d'ecriture arrive
|
||||
* au WT4) pour les tests de lecture / serialisation / contrat (DoD § 4.0.bis).
|
||||
*
|
||||
* Donnees (RETEX M1) : chaque test seede ses transporteurs ; le tearDown les
|
||||
* purge (cascade BDD sur les sous-collections) ainsi que les lignes
|
||||
* qualimat_carrier de test (prefixe SIRET dedie).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class AbstractCarrierApiTestCase extends AbstractApiTestCase
|
||||
{
|
||||
protected const string LD = 'application/ld+json';
|
||||
|
||||
/** Prefixe SIRET des lignes qualimat_carrier seedees par les tests (purge ciblee). */
|
||||
private const string TEST_SIRET_PREFIX = 'TESTQ';
|
||||
|
||||
/** Prefixe des Client/Supplier de test (cross-module Prix) — purge ciblee. */
|
||||
private const string TEST_REF_PREFIX = 'TESTCARRIERREF';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
// Carrier d'abord : ON DELETE CASCADE purge carrier_price (FK RESTRICT vers
|
||||
// client/supplier), liberant les Client/Supplier de test pour leur purge.
|
||||
$em->createQuery('DELETE FROM '.Carrier::class)->execute();
|
||||
$em->createQuery('DELETE FROM '.ClientEntity::class.' c WHERE c.companyName LIKE :p')
|
||||
->setParameter('p', self::TEST_REF_PREFIX.'%')->execute();
|
||||
$em->createQuery('DELETE FROM '.Supplier::class.' s WHERE s.companyName LIKE :p')
|
||||
->setParameter('p', self::TEST_REF_PREFIX.'%')->execute();
|
||||
// qualimat_carrier : insere en DBAL brut (entite lecture seule) -> purge DBAL.
|
||||
$em->getConnection()->executeStatement(
|
||||
'DELETE FROM qualimat_carrier WHERE siret LIKE :p',
|
||||
['p' => self::TEST_SIRET_PREFIX.'%'],
|
||||
);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
protected function createAdminClient(): Client
|
||||
{
|
||||
return $this->authenticatedClient('admin', 'admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un transporteur minimal (nom en MAJUSCULES, comme le ferait le
|
||||
* futur Processor). Sert aux tests de liste / archivage.
|
||||
*/
|
||||
protected function seedCarrier(string $name, bool $isArchived = false): Carrier
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$carrier = new Carrier();
|
||||
$carrier->setName(mb_strtoupper($name, 'UTF-8'));
|
||||
$carrier->setCertificationType('GMP_PLUS');
|
||||
$carrier->setIsArchived($isArchived);
|
||||
if ($isArchived) {
|
||||
$carrier->setArchivedAt(new DateTimeImmutable());
|
||||
}
|
||||
$em->persist($carrier);
|
||||
$em->flush();
|
||||
|
||||
return $carrier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un transporteur COMPLET (sans passer par l'API) : lien QUALIMAT,
|
||||
* 1 adresse, 1 contact, et 2 prix couvrant les deux branches (CLIENT avec
|
||||
* client + adresse de livraison + site de depart ; FOURNISSEUR avec
|
||||
* fournisseur + adresse d'appro + site de livraison). Socle du contrat de
|
||||
* serialisation et de la DoD (§ 4.0.bis).
|
||||
*/
|
||||
protected function seedCompleteCarrier(string $name): Carrier
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
|
||||
$qualimat = $this->seedQualimatCarrier($name);
|
||||
|
||||
$carrier = new Carrier();
|
||||
$carrier->setName(mb_strtoupper($name.' '.$suffix, 'UTF-8'));
|
||||
$carrier->setQualimatCarrier($qualimat);
|
||||
$carrier->setCertificationType('QUALIMAT');
|
||||
$em->persist($carrier);
|
||||
|
||||
$address = new CarrierAddress();
|
||||
$address->setCarrier($carrier);
|
||||
$address->setPostalCode('86000');
|
||||
$address->setCity('Poitiers');
|
||||
$address->setStreet('12 rue des Acacias');
|
||||
$carrier->addAddress($address);
|
||||
$em->persist($address);
|
||||
|
||||
$contact = new CarrierContact();
|
||||
$contact->setCarrier($carrier);
|
||||
$contact->setFirstName('Marie');
|
||||
$contact->setLastName('Martin');
|
||||
$contact->setPhonePrimary('0612345678');
|
||||
$contact->setEmail('marie.martin@seed.test');
|
||||
$carrier->addContact($contact);
|
||||
$em->persist($contact);
|
||||
|
||||
// Refs cross-module : seedees localement (en test, les fixtures M1/M2 ne
|
||||
// sont pas chargees — seuls les sites le sont). Prouve l'embed via les
|
||||
// contrats Shared + resolve_target_entities (regle n°1).
|
||||
$site = $em->getRepository(Site::class)->findOneBy([]);
|
||||
self::assertNotNull($site, 'Un site fixture est requis (SitesFixtures).');
|
||||
|
||||
$clientAddress = $this->seedClientWithAddress($name.' '.$suffix);
|
||||
$supplierAddress = $this->seedSupplierWithAddress($name.' '.$suffix);
|
||||
|
||||
// Branche CLIENT (RG-4.10).
|
||||
$clientPrice = new CarrierPrice();
|
||||
$clientPrice->setCarrier($carrier);
|
||||
$clientPrice->setDirection('CLIENT');
|
||||
$clientPrice->setClient($clientAddress->getClient());
|
||||
$clientPrice->setClientDeliveryAddress($clientAddress);
|
||||
$clientPrice->setDepartureSite($site);
|
||||
$clientPrice->setContainerType('BENNE');
|
||||
$clientPrice->setPricingUnit('TONNE');
|
||||
$clientPrice->setPrice('42.50');
|
||||
$clientPrice->setPriceState('VALIDE');
|
||||
$carrier->addPrice($clientPrice);
|
||||
$em->persist($clientPrice);
|
||||
|
||||
// Branche FOURNISSEUR (RG-4.11).
|
||||
$supplierPrice = new CarrierPrice();
|
||||
$supplierPrice->setCarrier($carrier);
|
||||
$supplierPrice->setDirection('FOURNISSEUR');
|
||||
$supplierPrice->setSupplier($supplierAddress->getSupplier());
|
||||
$supplierPrice->setSupplierSupplyAddress($supplierAddress);
|
||||
$supplierPrice->setDeliverySite($site);
|
||||
$supplierPrice->setContainerType('FOND_MOUVANT');
|
||||
$supplierPrice->setPricingUnit('FORFAIT');
|
||||
$supplierPrice->setPrice('320.00');
|
||||
$supplierPrice->setPriceState('EN_COURS');
|
||||
$carrier->addPrice($supplierPrice);
|
||||
$em->persist($supplierPrice);
|
||||
|
||||
$em->flush();
|
||||
|
||||
return $carrier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un Client minimal (companyName prefixe pour la purge) + une adresse
|
||||
* de livraison valide (CHECKs client_address respectes). Retourne l'adresse.
|
||||
*/
|
||||
protected function seedClientWithAddress(string $label): ClientAddress
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
|
||||
$client = new ClientEntity();
|
||||
$client->setCompanyName(mb_strtoupper(self::TEST_REF_PREFIX.' CLI '.$label.' '.$suffix, 'UTF-8'));
|
||||
$em->persist($client);
|
||||
|
||||
$address = new ClientAddress();
|
||||
$address->setClient($client);
|
||||
// Adresse de livraison : is_delivery=true, is_prospect=false, is_billing=false
|
||||
// -> satisfait chk_client_address_prospect_exclusive + chk_client_address_billing_email.
|
||||
$address->setIsDelivery(true);
|
||||
$address->setPostalCode('86000');
|
||||
$address->setCity('Poitiers');
|
||||
$address->setStreet('1 rue de la Livraison');
|
||||
$em->persist($address);
|
||||
|
||||
return $address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un Supplier minimal (companyName prefixe pour la purge) + une adresse
|
||||
* d'approvisionnement valide (address_type DEPART). Retourne l'adresse.
|
||||
*/
|
||||
protected function seedSupplierWithAddress(string $label): SupplierAddress
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
|
||||
$supplier = new Supplier();
|
||||
$supplier->setCompanyName(mb_strtoupper(self::TEST_REF_PREFIX.' FRN '.$label.' '.$suffix, 'UTF-8'));
|
||||
$em->persist($supplier);
|
||||
|
||||
$address = new SupplierAddress();
|
||||
$address->setSupplier($supplier);
|
||||
$address->setAddressType('DEPART');
|
||||
$address->setPostalCode('17000');
|
||||
$address->setCity('La Rochelle');
|
||||
$address->setStreet('2 quai de l Appro');
|
||||
$em->persist($address);
|
||||
|
||||
return $address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insere une ligne qualimat_carrier de test en DBAL brut (l'entite mappee est
|
||||
* en lecture seule) et retourne l'entite rechargee. SIRET prefixe pour la purge.
|
||||
*/
|
||||
protected function seedQualimatCarrier(string $name): QualimatCarrier
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$siret = self::TEST_SIRET_PREFIX.substr(bin2hex(random_bytes(6)), 0, 9);
|
||||
|
||||
$em->getConnection()->insert('qualimat_carrier', [
|
||||
'siret' => $siret,
|
||||
'name' => mb_strtoupper($name, 'UTF-8'),
|
||||
'address' => '12 rue des Acacias',
|
||||
'postal_code' => '86000',
|
||||
'city' => 'Poitiers',
|
||||
'status' => 'Valide',
|
||||
'validity_date' => '2027-12-31',
|
||||
'is_active' => 'true',
|
||||
'last_synced_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$qualimat = $em->getRepository(QualimatCarrier::class)->findOneBy(['siret' => $siret]);
|
||||
self::assertNotNull($qualimat, 'La ligne qualimat_carrier de test doit etre rechargeable.');
|
||||
|
||||
return $qualimat;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
/**
|
||||
* Tests du CONTRAT DE SERIALISATION du repertoire transporteurs (M4, spec-back
|
||||
* § 4.0 / § 4.0.bis). Jumeau de {@see \App\Tests\Module\Commercial\Api\SupplierSerializationContractTest}.
|
||||
* Reverifie sur le JSON REEL les pieges silencieux du M1 transposes au M4 :
|
||||
* - #1/#2 : relations embarquees en OBJET (pas IRI nu) — qualimatCarrier, et au
|
||||
* detail prices[].client / .supplier / .departureSite / .deliverySite.
|
||||
* - #3 : booleens isArchived / isChartered presents dans le JSON (Groups +
|
||||
* SerializedName sur le getter).
|
||||
* - enveloppe AP4 (member/totalItems/view sans prefixe hydra:) + exclusion des
|
||||
* archives par defaut, ?includeArchived=true les reintegre.
|
||||
*
|
||||
* REGLE D'OR : on asserte sur le CORPS JSON reel, jamais sur les annotations.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierSerializationContractTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
// === Enveloppe AP4 + exclusion des archives (§ 4.1) ===
|
||||
|
||||
public function testCollectionEnvelopeShapeAndArchivedExcluded(): void
|
||||
{
|
||||
$http = $this->createAdminClient();
|
||||
$token = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
|
||||
$this->seedCarrier($token.' Active');
|
||||
$this->seedCarrier($token.' Archived', true);
|
||||
|
||||
$default = $http->request('GET', '/api/carriers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayHasKey('member', $default);
|
||||
self::assertArrayHasKey('totalItems', $default);
|
||||
self::assertArrayNotHasKey('hydra:member', $default);
|
||||
self::assertArrayNotHasKey('hydra:totalItems', $default);
|
||||
self::assertSame(1, $default['totalItems'], 'Archive exclu du totalItems par defaut.');
|
||||
|
||||
$all = $http->request('GET', '/api/carriers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertSame(2, $all['totalItems']);
|
||||
|
||||
$paged = $http->request('GET', '/api/carriers?search='.$token.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertArrayHasKey('view', $paged);
|
||||
self::assertArrayNotHasKey('hydra:view', $paged);
|
||||
}
|
||||
|
||||
// === #3 — Booleens presents (isArchived) + embed qualimatCarrier en LISTE ===
|
||||
|
||||
public function testListExposesIsArchivedAndEmbeddedQualimat(): void
|
||||
{
|
||||
$token = 'List'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
$carrier = $this->seedCompleteCarrier($token);
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$list = $http->request('GET', '/api/carriers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
$row = $this->memberById($list, (int) $carrier->getId());
|
||||
self::assertNotNull($row, 'Le transporteur seede doit apparaitre dans la liste filtree.');
|
||||
|
||||
// Boolean trap (#3) : cle presente et typee bool.
|
||||
self::assertArrayHasKey('isArchived', $row);
|
||||
self::assertFalse($row['isArchived']);
|
||||
|
||||
// qualimatCarrier embarque en OBJET (statut + date de validite — RG-4.04),
|
||||
// pas un IRI nu (#1/#2).
|
||||
self::assertArrayHasKey('qualimatCarrier', $row);
|
||||
self::assertIsArray($row['qualimatCarrier'], 'qualimatCarrier doit etre un objet embarque, pas un IRI nu.');
|
||||
self::assertArrayHasKey('status', $row['qualimatCarrier']);
|
||||
self::assertArrayHasKey('validityDate', $row['qualimatCarrier']);
|
||||
|
||||
// updatedAt (default:read) expose pour la colonne « Derniere activite ».
|
||||
self::assertArrayHasKey('updatedAt', $row);
|
||||
}
|
||||
|
||||
// === Detail : sous-collections embarquees + booleens ===
|
||||
|
||||
public function testDetailEmbedsSubCollectionsAndBooleans(): void
|
||||
{
|
||||
$carrier = $this->seedCompleteCarrier('Detail Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/carriers/'.$carrier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayHasKey('isArchived', $data);
|
||||
self::assertArrayHasKey('isChartered', $data);
|
||||
self::assertFalse($data['isArchived']);
|
||||
|
||||
self::assertNotEmpty($data['addresses']);
|
||||
self::assertSame('Poitiers', $data['addresses'][0]['city']);
|
||||
|
||||
self::assertNotEmpty($data['contacts']);
|
||||
self::assertSame('Marie', $data['contacts'][0]['firstName']);
|
||||
|
||||
self::assertNotEmpty($data['prices']);
|
||||
self::assertGreaterThanOrEqual(2, count($data['prices']));
|
||||
}
|
||||
|
||||
// === #1/#2 — prices[] : client / supplier / sites embarques en OBJET ===
|
||||
|
||||
public function testPriceCrossModuleRelationsAreEmbeddedObjects(): void
|
||||
{
|
||||
$carrier = $this->seedCompleteCarrier('Price Embed Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/carriers/'.$carrier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
$byDirection = [];
|
||||
foreach ($data['prices'] as $price) {
|
||||
$byDirection[$price['direction']] = $price;
|
||||
}
|
||||
|
||||
self::assertArrayHasKey('CLIENT', $byDirection);
|
||||
self::assertArrayHasKey('FOURNISSEUR', $byDirection);
|
||||
|
||||
// Branche CLIENT : client + adresse + site de depart en OBJET (pas IRI).
|
||||
$clientPrice = $byDirection['CLIENT'];
|
||||
self::assertIsArray($clientPrice['client'], 'prices[].client doit etre un objet embarque (client:read), pas un IRI nu.');
|
||||
self::assertArrayHasKey('companyName', $clientPrice['client']);
|
||||
self::assertIsArray($clientPrice['clientDeliveryAddress']);
|
||||
self::assertArrayHasKey('city', $clientPrice['clientDeliveryAddress'], 'L\'adresse client doit embarquer ses champs (client_address:read).');
|
||||
self::assertIsArray($clientPrice['departureSite']);
|
||||
self::assertArrayHasKey('name', $clientPrice['departureSite']);
|
||||
|
||||
// Branche FOURNISSEUR : supplier + adresse + site de livraison en OBJET.
|
||||
$supplierPrice = $byDirection['FOURNISSEUR'];
|
||||
self::assertIsArray($supplierPrice['supplier'], 'prices[].supplier doit etre un objet embarque (supplier:read), pas un IRI nu.');
|
||||
self::assertArrayHasKey('companyName', $supplierPrice['supplier']);
|
||||
self::assertIsArray($supplierPrice['supplierSupplyAddress']);
|
||||
self::assertArrayHasKey('city', $supplierPrice['supplierSupplyAddress'], 'L\'adresse fournisseur doit embarquer ses champs (supplier_address:read).');
|
||||
self::assertIsArray($supplierPrice['deliverySite']);
|
||||
}
|
||||
|
||||
// === RBAC : 403 sans la permission view ===
|
||||
|
||||
public function testForbiddenWithoutViewPermission(): void
|
||||
{
|
||||
$carrier = $this->seedCarrier('Rbac Co');
|
||||
|
||||
// user-nothing : aucune permission transport.carriers.*.
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$http->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(403, $http->getResponse()->getStatusCode());
|
||||
|
||||
$http->request('GET', '/api/carriers/'.$carrier->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(403, $http->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* DoD (§ 4.0.bis) : capture des reponses JSON REELLES (liste + detail) pour
|
||||
* les coller dans la spec avant de lancer les tickets front. Le test asserte
|
||||
* la forme ; si CARRIER_DOD_DUMP est positionnee, ecrit les corps sous /tmp.
|
||||
*/
|
||||
public function testDodReferenceJsonShape(): void
|
||||
{
|
||||
$token = 'DoD'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
$carrier = $this->seedCompleteCarrier($token);
|
||||
$id = (int) $carrier->getId();
|
||||
|
||||
$admin = $this->createAdminClient();
|
||||
$list = $admin->request('GET', '/api/carriers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
$detail = $admin->request('GET', '/api/carriers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayHasKey('member', $list);
|
||||
self::assertArrayHasKey('qualimatCarrier', $detail);
|
||||
self::assertArrayHasKey('addresses', $detail);
|
||||
self::assertArrayHasKey('contacts', $detail);
|
||||
self::assertArrayHasKey('prices', $detail);
|
||||
|
||||
if (false !== getenv('CARRIER_DOD_DUMP')) {
|
||||
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
|
||||
file_put_contents('/tmp/carrier-dod-list.json', json_encode($list, $flags));
|
||||
file_put_contents('/tmp/carrier-dod-detail.json', json_encode($detail, $flags));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrouve un membre de la collection par son id (liste filtree).
|
||||
*
|
||||
* @param array<string, mixed> $collection
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function memberById(array $collection, int $id): ?array
|
||||
{
|
||||
foreach ($collection['member'] ?? [] as $member) {
|
||||
if (($member['id'] ?? null) === $id) {
|
||||
return $member;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Application\Idtf;
|
||||
|
||||
use App\Module\Transport\Application\Idtf\IdtfSheetParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class IdtfSheetParserTest extends TestCase
|
||||
{
|
||||
public function testExtractsExportDate(): void
|
||||
{
|
||||
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
|
||||
self::assertSame('2026-06-12', $parsed['exportDate']);
|
||||
}
|
||||
|
||||
public function testParsesAndNormalizesFirstRow(): void
|
||||
{
|
||||
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
|
||||
$row = $parsed['rows'][0];
|
||||
|
||||
self::assertSame(30748, $row['idtf_number']);
|
||||
self::assertSame('Argiles avec régime de nettoyage C', $row['name']);
|
||||
self::assertSame('C', $row['cleaning_regime']);
|
||||
self::assertSame('2026-04-02', $row['mandatory_date']);
|
||||
self::assertSame('Al2O3', $row['formula']);
|
||||
self::assertSame('01 01 01', $row['eural_code']);
|
||||
self::assertSame(['7631-86-9', '1344-28-1'], $row['cas_numbers']);
|
||||
self::assertSame('Note 1', $row['footnotes']);
|
||||
}
|
||||
|
||||
public function testSkipsEmptyAndNonNumericRows(): void
|
||||
{
|
||||
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
|
||||
|
||||
// 2 lignes exploitables (30748 et 30744) ; vide + "abc" ignorees.
|
||||
self::assertCount(2, $parsed['rows']);
|
||||
self::assertSame(30744, $parsed['rows'][1]['idtf_number']);
|
||||
}
|
||||
|
||||
public function testEmptyOptionalCellsBecomeNullAndCasEmpty(): void
|
||||
{
|
||||
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
|
||||
$row = $parsed['rows'][1]; // 30744
|
||||
|
||||
self::assertNull($row['mandatory_date']);
|
||||
self::assertNull($row['formula']);
|
||||
self::assertNull($row['product_group']);
|
||||
self::assertSame([], $row['cas_numbers']);
|
||||
}
|
||||
|
||||
public function testColumnOrderIsResolvedByLabel(): void
|
||||
{
|
||||
// En-tete dans un ordre different : le mapping doit suivre les libelles.
|
||||
$matrix = [
|
||||
['Export date: 1-1-2026'],
|
||||
['Numéro CAS', 'Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'],
|
||||
['7440-44-0', '99', 'Carbone', 'B'],
|
||||
];
|
||||
|
||||
$parsed = IdtfSheetParser::parse($matrix);
|
||||
$row = $parsed['rows'][0];
|
||||
|
||||
self::assertSame(99, $row['idtf_number']);
|
||||
self::assertSame('Carbone', $row['name']);
|
||||
self::assertSame('B', $row['cleaning_regime']);
|
||||
self::assertSame(['7440-44-0'], $row['cas_numbers']);
|
||||
}
|
||||
|
||||
public function testThrowsWhenHeaderMissing(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
IdtfSheetParser::parse([['foo', 'bar'], ['1', '2']]);
|
||||
}
|
||||
|
||||
public function testExportDateNullWhenAbsent(): void
|
||||
{
|
||||
$matrix = [
|
||||
['Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'],
|
||||
['1', 'X', 'A'],
|
||||
];
|
||||
|
||||
self::assertNull(IdtfSheetParser::parse($matrix)['exportDate']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matrice representative de l'export reel : preambule (lignes 0-1), ligne
|
||||
* vide (2), en-tete (3) puis donnees.
|
||||
*
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
private function sampleMatrix(): array
|
||||
{
|
||||
return [
|
||||
['Export date: 12-6-2026'],
|
||||
['Changes in the database after this date...'],
|
||||
[],
|
||||
['Numéro IDTF', 'Product Group', 'Nom de la marchandise', 'Régime de nettoyage', 'Exigences importantes', 'Date d’application obligatoire', 'Produits apparentés', 'Formule', 'Code EURAL', 'Numéro CAS', 'Annotations'],
|
||||
['30748', 'Substances inorganiques', 'Argiles avec régime de nettoyage C', 'C', 'Exigence X', '02-04-2026', 'Poudre argile', 'Al2O3', '01 01 01', '7631-86-9 ; 1344-28-1', 'Note 1'],
|
||||
['', '', '', '', '', '', '', '', '', '', ''],
|
||||
['abc', 'ligne non numerique a ignorer', '', '', '', '', '', '', '', '', ''],
|
||||
['30744', '', 'Additifs alimentaires', 'A', '', '', '', '', '', '', ''],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
/**
|
||||
* Test fonctionnel de `app:idtf:sync` via --file : genere un vrai .xlsx, le
|
||||
* passe a la commande et verifie le parsing, l'upsert, le journal et le
|
||||
* soft-delete (chemin complet IOFactory -> parser -> DBAL).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SyncIdtfCommandTest extends KernelTestCase
|
||||
{
|
||||
private Connection $connection;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
/** @var Connection $connection */
|
||||
$connection = self::getContainer()->get('doctrine.dbal.default_connection');
|
||||
$this->connection = $connection;
|
||||
$this->purge();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->purge();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testSyncParsesXlsxUpsertsAndLogs(): void
|
||||
{
|
||||
$path = $this->makeXlsx([
|
||||
['Export date: 12-6-2026'],
|
||||
['Avertissement preambule'],
|
||||
[],
|
||||
['Numéro IDTF', 'Product Group', 'Nom de la marchandise', 'Régime de nettoyage', 'Exigences importantes', 'Date d’application obligatoire', 'Produits apparentés', 'Formule', 'Code EURAL', 'Numéro CAS', 'Annotations'],
|
||||
['30748', 'Inorganiques', 'Argiles régime C', 'C', 'Exig X', '02-04-2026', 'Poudre', 'Al2O3', '01 01 01', '7631-86-9 ; 1344-28-1', 'Note'],
|
||||
['', '', '', '', '', '', '', '', '', '', ''],
|
||||
['30744', '', 'Additifs', 'A', '', '', '', '', '', '', ''],
|
||||
]);
|
||||
|
||||
$tester = $this->runSync($path);
|
||||
$tester->assertCommandIsSuccessful();
|
||||
|
||||
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM idtf_product'));
|
||||
|
||||
$row = $this->connection->fetchAssociative("SELECT * FROM idtf_product WHERE idtf_number = 30748 AND schema = 'road'");
|
||||
self::assertNotFalse($row);
|
||||
self::assertSame('Argiles régime C', $row['name']);
|
||||
self::assertSame('C', $row['cleaning_regime']);
|
||||
self::assertSame('2026-04-02', $row['mandatory_date']);
|
||||
self::assertSame('2026-06-12', $row['source_export_date']);
|
||||
self::assertSame(['7631-86-9', '1344-28-1'], json_decode((string) $row['cas_numbers'], true));
|
||||
|
||||
$log = $this->connection->fetchAssociative('SELECT * FROM idtf_sync_log ORDER BY id DESC LIMIT 1');
|
||||
self::assertNotFalse($log);
|
||||
self::assertSame('road', $log['schema']);
|
||||
self::assertSame('2026-06-12', $log['export_date']);
|
||||
self::assertSame(2, (int) $log['rows_total']);
|
||||
self::assertSame(2, (int) $log['rows_upserted']);
|
||||
self::assertSame(0, (int) $log['rows_deactivated']);
|
||||
}
|
||||
|
||||
public function testSecondSyncSoftDeletesMissing(): void
|
||||
{
|
||||
$header = ['Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'];
|
||||
|
||||
$this->runSync($this->makeXlsx([
|
||||
['Export date: 1-6-2026'],
|
||||
$header,
|
||||
['100', 'Produit 100', 'A'],
|
||||
['200', 'Produit 200', 'B'],
|
||||
]))->assertCommandIsSuccessful();
|
||||
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM idtf_product WHERE is_active = TRUE'));
|
||||
|
||||
// 2e export sans 200 -> soft-delete de 200, mise a jour de 100.
|
||||
$tester = $this->runSync($this->makeXlsx([
|
||||
['Export date: 2-6-2026'],
|
||||
$header,
|
||||
['100', 'Produit 100 maj', 'C'],
|
||||
]));
|
||||
$tester->assertCommandIsSuccessful();
|
||||
|
||||
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM idtf_product'));
|
||||
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM idtf_product WHERE is_active = TRUE'));
|
||||
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM idtf_product WHERE idtf_number = 200 AND is_active = FALSE'));
|
||||
|
||||
$row100 = $this->connection->fetchAssociative('SELECT * FROM idtf_product WHERE idtf_number = 100');
|
||||
self::assertNotFalse($row100);
|
||||
self::assertSame('Produit 100 maj', $row100['name']);
|
||||
self::assertSame('C', $row100['cleaning_regime']);
|
||||
|
||||
$log = $this->connection->fetchAssociative('SELECT * FROM idtf_sync_log ORDER BY id DESC LIMIT 1');
|
||||
self::assertNotFalse($log);
|
||||
self::assertSame(1, (int) $log['rows_upserted']);
|
||||
self::assertSame(1, (int) $log['rows_deactivated']);
|
||||
}
|
||||
|
||||
public function testInvalidSchemaIsRejected(): void
|
||||
{
|
||||
$path = $this->makeXlsx([
|
||||
['Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'],
|
||||
['1', 'X', 'A'],
|
||||
]);
|
||||
|
||||
$application = new Application(self::$kernel);
|
||||
$tester = new CommandTester($application->find('app:idtf:sync'));
|
||||
$exitCode = $tester->execute(['--file' => $path, '--schema' => 'air']);
|
||||
|
||||
@unlink($path);
|
||||
|
||||
self::assertSame(2, $exitCode); // Command::INVALID
|
||||
self::assertSame(0, $this->countRows('SELECT COUNT(*) FROM idtf_product'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<int, mixed>> $matrix
|
||||
*/
|
||||
private function makeXlsx(array $matrix): string
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$spreadsheet->getActiveSheet()->fromArray($matrix, null, 'A1', true);
|
||||
|
||||
$path = tempnam(sys_get_temp_dir(), 'idtf_').'.xlsx';
|
||||
new Xlsx($spreadsheet)->save($path);
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function runSync(string $path): CommandTester
|
||||
{
|
||||
$application = new Application(self::$kernel);
|
||||
$tester = new CommandTester($application->find('app:idtf:sync'));
|
||||
$tester->execute(['--file' => $path, '--schema' => 'road']);
|
||||
|
||||
@unlink($path);
|
||||
|
||||
return $tester;
|
||||
}
|
||||
|
||||
private function countRows(string $sql): int
|
||||
{
|
||||
return (int) $this->connection->fetchOne($sql);
|
||||
}
|
||||
|
||||
private function purge(): void
|
||||
{
|
||||
$this->connection->executeStatement('DELETE FROM idtf_product');
|
||||
$this->connection->executeStatement('DELETE FROM idtf_sync_log');
|
||||
}
|
||||
}
|
||||
@@ -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,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport;
|
||||
|
||||
use App\Module\Transport\TransportModule;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests structurels du module Transport (M4) : identite et contrat
|
||||
* `permissions()` (socle RBAC, ERP-153).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class TransportModuleTest extends TestCase
|
||||
{
|
||||
public function testModuleIdentity(): void
|
||||
{
|
||||
self::assertSame('transport', TransportModule::ID);
|
||||
self::assertSame('Transport', TransportModule::LABEL);
|
||||
self::assertFalse(TransportModule::REQUIRED);
|
||||
}
|
||||
|
||||
public function testPermissionsSetContainsExactlyThreeCodes(): void
|
||||
{
|
||||
// Garde-fou : le jeu de permissions du module est fige par ce test. Si
|
||||
// quelqu'un ajoute / retire une permission sans ajuster la spec (§ 5.1)
|
||||
// ni la matrice RBAC (§ 5.2), le test casse explicitement.
|
||||
$codes = array_column(TransportModule::permissions(), 'code');
|
||||
sort($codes);
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
'transport.carriers.archive',
|
||||
'transport.carriers.manage',
|
||||
'transport.carriers.view',
|
||||
],
|
||||
$codes,
|
||||
);
|
||||
}
|
||||
|
||||
public function testEveryPermissionCodeIsPrefixedByModuleId(): void
|
||||
{
|
||||
// Convention de nommage `module.resource[.sub].action` : le prefixe doit
|
||||
// correspondre exactement a l'ID du module (verifie aussi par
|
||||
// app:sync-permissions).
|
||||
foreach (TransportModule::permissions() as $permission) {
|
||||
self::assertStringStartsWith(
|
||||
TransportModule::ID.'.',
|
||||
$permission['code'],
|
||||
'Chaque code de permission doit etre prefixe par l\'ID du module.',
|
||||
);
|
||||
self::assertNotSame('', $permission['label'], 'Chaque permission doit porter un label.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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