Compare commits

..

1 Commits

Author SHA1 Message Date
gitea-actions 9e908ab1a5 chore: bump version to v0.1.112
Build & Push Docker Image / build (push) Successful in 23s
2026-06-12 14:27:22 +00:00
140 changed files with 405 additions and 20773 deletions
+2 -2
View File
@@ -24,7 +24,6 @@
"symfony/expression-language": "8.0.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/intl": "8.0.*",
"symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0",
@@ -96,6 +95,7 @@
"doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.94",
"phpunit/phpunit": "^13.0",
"symfony/browser-kit": "8.0.*"
"symfony/browser-kit": "8.0.*",
"symfony/http-client": "8.0.*"
}
}
Generated
+175 -175
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "b029c1484227c926d39dfd3ae5cb0699",
"content-hash": "2dc5db01e7f5d6aecd5956749b21a092",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -5412,180 +5412,6 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client",
"version": "v8.0.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "c7f40f9103233630167c25c9a4570acf805fdade"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/c7f40f9103233630167c25c9a4570acf805fdade",
"reference": "c7f40f9103233630167c25c9a4570acf805fdade",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/cache": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v8.0.13"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-24T09:58:02+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/http-foundation",
"version": "v8.0.8",
@@ -11959,6 +11785,180 @@
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e",
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/cache": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/process",
"version": "v8.0.8",
-2
View File
@@ -6,7 +6,6 @@ use App\Module\Commercial\CommercialModule;
use App\Module\Core\CoreModule;
use App\Module\Sites\SitesModule;
use App\Module\Technique\TechniqueModule;
use App\Module\Transport\TransportModule;
return [
CoreModule::class,
@@ -14,5 +13,4 @@ return [
SitesModule::class,
CatalogModule::class,
TechniqueModule::class,
TransportModule::class,
];
-3
View File
@@ -12,9 +12,6 @@ api_platform:
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
# en dehors de Domain/Entity : AuditLogResource, etc.
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
# Entites techniques partagees portant un #[ApiResource]
# (UploadedDocument — infra upload generique ERP-154).
- '%kernel.project_dir%/src/Shared/Domain/Entity'
formats:
jsonld: ['application/ld+json']
json: ['application/json']
+10 -63
View File
@@ -8,29 +8,16 @@ doctrine:
default:
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
# Exclut certaines tables de toute operation de comparaison de
# schema (doctrine:schema:update, schema:validate, diff de
# migrations...). Ces tables n'ont volontairement aucune entite
# mappee :
# - `audit_log` : append-only via DBAL brut (AuditLogWriter) pour
# eviter la recursion du listener Doctrine.
# - `qualimat_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)$).+~'
# Exclut `audit_log` de toute operation de comparaison de schema
# (doctrine:schema:update, schema:validate, diff de migrations...).
# Cette table n'a volontairement aucune entite mappee : elle est
# append-only via DBAL brut (AuditLogWriter) pour eviter la
# recursion du listener Doctrine. Sans ce filtre, schema:update
# la considere comme "orpheline" et genere un `DROP TABLE
# audit_log` qui casse la base de test apres chaque
# `make test-db-setup`. La creation / suppression de la table
# reste pilotee par les migrations (cf. Version20260420202749).
schema_filter: '~^(?!audit_log$).+~'
audit:
url: '%env(resolve:DATABASE_URL)%'
orm:
@@ -54,26 +41,7 @@ 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
@@ -112,27 +80,6 @@ doctrine:
dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity'
prefix: 'App\Module\Commercial\Domain\Entity'
alias: Commercial
# Mapping inconditionnel du module Technique (meme logique que Commercial) :
# les tables prestataires (provider + sous-collections + jointures M2M)
# creees par la migration M3 (Version20260612100000) doivent etre connues
# de l'ORM. L'activation fonctionnelle passe par config/modules.php.
Technique:
type: attribute
is_bundle: false
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
-1
View File
@@ -2,5 +2,4 @@ doctrine_migrations:
migrations_paths:
'DoctrineMigrations': '%kernel.project_dir%/migrations'
'App\Module\Core\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Core/Infrastructure/Doctrine/Migrations'
'App\Module\Transport\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Transport/Infrastructure/Doctrine/Migrations'
enable_profiler: false
-13
View File
@@ -1,13 +0,0 @@
# Active le composant HTTP Client (symfony/http-client) et enregistre
# l'autowiring de HttpClientInterface. Utilise par les commandes de
# synchronisation de referentiels externes (QUALIMAT, IDTF...).
#
# User-Agent navigateur neutre : les sources (qualimat.org sous WordPress/WAF,
# icrt-idtf.com) filtrent souvent les UA de bibliotheque/vides ; un UA de type
# navigateur evite les blocages anti-bot sans reveler l'application.
framework:
http_client:
default_options:
timeout: 30
headers:
User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
-34
View File
@@ -61,40 +61,6 @@ return [
],
],
],
// Section "Technique" (M3, ERP-138) : pole distinct du Commercial, porte le
// repertoire prestataires. L'item est gate par `technique.providers.view` ;
// la section disparait automatiquement (SidebarProvider) si le module
// `technique` est desactive ou si l'user n'a pas la permission.
[
'label' => 'sidebar.technique.section',
'icon' => 'mdi:account-convert-outline',
'items' => [
[
'label' => 'sidebar.technique.providers',
'to' => '/providers',
'icon' => 'mdi:account-wrench-outline',
'module' => 'technique',
'permission' => 'technique.providers.view',
],
],
],
// Section "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
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.126'
app.version: '0.1.112'
+52 -138
View File
@@ -176,7 +176,7 @@ Anti-N+1 (le code fera foi) : le `DoctrineProviderRepository` ne fetch-joine PAS
- **Filtre LISTE** (`ProviderProvider` ou query extension dédiée `ProviderSiteScopeExtension`) : si l'user **n'a pas** `sites.bypass_scope` ET que `CurrentSiteProvider::get()` retourne un site → ne renvoyer que les prestataires dont `provider.sites` **contient** le `currentSite` (jointure `provider_site` + `WHERE site = :currentSite`). Si l'user a `bypass_scope` (Admin, profils consolidation) → aucun filtre (tous sites). Si `currentSite = null` (mode dégradé / module Sites off) → aligné `site-aware.md § 5` (no-op lecture, à documenter).
- **Filtre DÉTAIL** (`Get`) : un user sans `bypass_scope` qui demande un prestataire **hors de son site courant****404** (cohérence : ne pas révéler l'existence d'une ligne hors périmètre).
- **Écriture (décision Matthieu, 11/06)** : un user **sans** `bypass_scope` ne peut attacher **que les sites dont il dispose** (ses `user_site`) — sur le formulaire principal (`provider.sites`, RG-3.03) **comme** sur chaque adresse (`provider_address.sites`, RG-3.05). Tout site hors de ses `user_site` dans le payload → **422** sur `sites`. Un user `bypass_scope` (Admin) peut attacher n'importe quel site. Garde porté par le `ProviderProcessor` (POST + PATCH + sous-ressource adresses).
- **Cohérence sous-ressources** (Contacts / Adresses / RIB) : le cloisonnement du parent **n'est PAS hérité automatiquement** — les opérations `Get` / `Patch` / `Delete` des sous-ressources passent par le provider Doctrine par défaut (et `SiteScopedQueryExtension` ne filtre que les `SiteAwareInterface`, ce que ces entités ne sont pas). Le garde-fou est donc posé **explicitement** : (a) en lecture/édition/suppression via le provider décoré `ProviderSubResourceItemProvider` (un parent hors périmètre → **404**) ; (b) en création (`POST /providers/{id}/...`) via `ProviderSiteScopeChecker::assertInScope` dans chaque processor (parent hors périmètre → **404**). La décision de scope est centralisée dans `ProviderSiteScopeChecker` (source unique partagée avec `ProviderProvider`). ⚠️ Ne pas retirer ces gardes en les croyant redondants : sans eux, un user cloisonné peut lire l'IBAN/BIC d'un RIB d'un autre site.
- **Cohérence sous-ressources** (`/providers/{id}/...`) : le détail étant déjà gardé en 404 hors périmètre, les sous-ressources héritent du garde-fou parent (cf. `site-aware.md § 6.1`).
> **Conséquence RBAC** : la colonne « Consultation » du docx (« Tout » vs « son site uniquement ») se réalise **par `sites.bypass_scope`**, pas par le code de rôle. Décision d'attribution par défaut (à acter au ticket RBAC) : `bypass_scope` aux profils Admin (auto) + Bureau + Compta + Commerciale (ils voient « Tout » d'après le docx) ; **Usine ne l'a pas** → cloisonné à son site. Si MALIO préfère que Bureau/Commerciale soient aussi cloisonnés, il suffit de ne pas leur donner `bypass_scope` — **aucun code à changer** (c'est l'intérêt de piloter par user/permission et non par rôle).
@@ -624,153 +624,67 @@ Même pattern que les jumelles `Supplier*` (`#[Auditable]`, `TimestampableBlamab
> 1. Réfs comptables (`tvaMode`/`paymentDelay`/`paymentType`/`bank`) : doivent sortir en **objet `{id, code, label}`**, pas en IRI nu → vérifier que les entités partagées portent bien le groupe `provider:read:accounting` (sinon les annoter, comme le fix ERP-92 l'a fait pour `supplier:read:accounting`).
> 2. Gating compta par **omission de clé** : pour un user sans `accounting.view`, les clés `siren`/`tvaMode`/`ribs`/… sont **absentes** (pas `null`).
> **✅ Capturé sur l'API réelle (ERP-139)** via `ProviderSerializationContractTest::testDodReferenceJsonShape` (`PROVIDER_DOD_DUMP=1`). Les `id`/`companyName`/noms de catégorie ci-dessous proviennent du prestataire seedé par le test (`seedCompleteProvider`) ; la **forme** (clés, embed, gating) est le contrat réel à respecter côté front.
`GET /api/providers` (liste, ADMIN avec `accounting.view` — un membre, capture réelle) :
`GET /api/providers` (liste, ADMIN — un membre, forme attendue) :
```json
{
"@context": "/api/contexts/Provider",
"@id": "/api/providers",
"@type": "Collection",
"totalItems": 1,
"member": [
{
"@id": "/api/providers/572",
"@type": "Provider",
"id": 572,
"companyName": "DOD21AADC 0E3CCE",
"categories": [
{
"@type": "Category",
"@id": "/api/categories/3006",
"id": 3006,
"name": "test_prov_cat_nettoyage",
"code": "NETTOYAGE",
"categoryTypes": [
{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}
],
"createdAt": "2026-06-12T15:17:29+02:00",
"updatedAt": "2026-06-12T15:17:29+02:00"
}
],
"sites": [
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
],
"siren": "987654321",
"accountNumber": "P0001",
"tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"},
"nTva": "FR00987654321",
"paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"},
"paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"},
"ribs": [
{"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"createdAt": "2026-06-12T15:17:29+02:00",
"updatedAt": "2026-06-12T15:17:29+02:00",
"isArchived": false
}
],
"view": {"@id": "/api/providers?search=DoD21aadc", "@type": "PartialCollectionView"}
"@context": "/api/contexts/Provider",
"@id": "/api/providers",
"@type": "Collection",
"totalItems": 1,
"member": [
{
"@id": "/api/providers/1", "@type": "Provider", "id": 1,
"companyName": "MAINTENANCE PRO SAS",
"categories": [
{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE",
"categoryType": {"@id": "/api/category_types/3", "@type": "CategoryType", "id": 3, "code": "PRESTATAIRE", "label": "Prestataire"}}
],
"sites": [
{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}
],
"siren": "987654321", "accountNumber": "P0001",
"tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
"paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
"ribs": [
{"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}
],
"updatedAt": "2026-06-11T10:00:00+02:00",
"isArchived": false
}
],
"view": {"@id": "/api/providers", "@type": "PartialCollectionView"}
}
```
> Les `sites[]` de la liste sont la **relation directe** `provider.sites` (formulaire principal — RG-3.03), objet `Site` entier (pas un IRI nu). Les catégories embarquent `code` + `name`. Les prestataires archivés sont **exclus** du `totalItems` (RG-3.16). Pour un profil **sans `accounting.view`** (ex. Commerciale), `siren`/`accountNumber`/`tvaMode`/`nTva`/`paymentDelay`/`paymentType`/`bank`/`ribs` **disparaissent** de chaque membre (gating par omission — cf. détail restreint ci-dessous).
> Les prestataires archivés sont **exclus** du `totalItems` (RG-3.16). Pour la **Commerciale** (sans `accounting.view`), `siren`/`tvaMode`/`paymentType`/`ribs` **disparaissent** de chaque membre.
`GET /api/providers/{id}` (détail — user **avec** `accounting.view`, capture réelle) :
`GET /api/providers/{id}` (détail — user avec `accounting.view`, forme attendue) :
```json
{
"@context": "/api/contexts/Provider",
"@id": "/api/providers/572",
"@type": "Provider",
"id": 572,
"companyName": "DOD21AADC 0E3CCE",
"categories": [
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"sites": [
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
],
"siren": "987654321",
"accountNumber": "P0001",
"tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"},
"nTva": "FR00987654321",
"paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"},
"paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"},
"contacts": [
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"addresses": [
{
"@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35,
"country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
"sites": [
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
],
"contacts": [
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"categories": [
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"
}
],
"ribs": [
{"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"createdAt": "2026-06-12T15:17:29+02:00",
"updatedAt": "2026-06-12T15:17:29+02:00",
"isArchived": false
"@id": "/api/providers/1", "@type": "Provider", "id": 1,
"companyName": "MAINTENANCE PRO SAS",
"categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}],
"sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
"siren": "987654321", "accountNumber": "P0001",
"tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
"nTva": "FR00987654321",
"paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"},
"paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
"contacts": [
{"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"}
],
"addresses": [
{"@id": "/api/provider_addresses/1", "@type": "ProviderAddress", "id": 1, "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
"sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
"contacts": [{"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin"}],
"categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}]}
],
"ribs": [{"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}],
"isArchived": false
}
```
`GET /api/providers/{id}` (même prestataire, user **sans** `accounting.view` — capture réelle) :
```json
{
"@context": "/api/contexts/Provider",
"@id": "/api/providers/572",
"@type": "Provider",
"id": 572,
"companyName": "DOD21AADC 0E3CCE",
"categories": [
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"sites": [
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
],
"contacts": [
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"addresses": [
{
"@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35,
"country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
"sites": [
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
],
"contacts": [
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"categories": [
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"
}
],
"createdAt": "2026-06-12T15:17:29+02:00",
"updatedAt": "2026-06-12T15:17:29+02:00",
"isArchived": false
}
```
> **Gating par omission confirmé sur le JSON réel** : pour un user **sans** `accounting.view`, les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank` **et `ribs`** sont **absentes** (pas `null`). `isArchived`, `contacts[]`, `addresses[]` (avec `sites[]`/`contacts[]`/`categories[]`) restent exposés. Vérifié par `ProviderSerializationContractTest::testRibsAbsentForUserWithoutAccountingView` + `testAccountingScalarsGatedByOmission`.
>
> **Réfs comptables = objets embarqués `{id, code, label}`** (pas IRI nu) : le fix ERP-139 a ajouté `provider:read:accounting` sur `TvaMode`/`PaymentDelay`/`PaymentType`/`Bank` (réplique du fix ERP-92 du M2). Vérifié par `testAccountingReferentialsEmbedIdCodeLabel`.
> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (gating par omission — à confirmer par test).
### 4.1 `GET /api/providers` — Liste
@@ -1009,7 +923,7 @@ Cf. § 2.9 (matrice détaillée — identique à la matrice M2 transposée sur `
- [x] 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0)
- [x] Décision embed vs GetCollection explicite et câblée (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5), **pas de POST-only**
- [x] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — liste + détail (avec/sans `accounting.view`) collés depuis `ProviderSerializationContractTest` (ERP-139)
- [ ] **Réponses JSON RÉELLES** à capturer (§ 4.0.bis) — gabarit posé, capture à faire au 1er ticket back (DoD avant front)
- [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-3.15)
- [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit + i18n, routes à plat : rappelés
- [x] Réutilisations M1/M2 identifiées (référentiels compta partagés, taxonomie code/type, filtre `?typeCode=`, `usePaginatedList`, blocs, archive, normalisation, `useAddressAutocomplete`)
@@ -1,100 +0,0 @@
# 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.
@@ -1,44 +0,0 @@
# 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.
@@ -1,37 +0,0 @@
# 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.
@@ -1,45 +0,0 @@
# 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.
@@ -1,54 +0,0 @@
# 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.
@@ -1,41 +0,0 @@
# 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.
@@ -1,37 +0,0 @@
# 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.
@@ -1,37 +0,0 @@
# 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.
@@ -1,35 +0,0 @@
# 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.
@@ -1,39 +0,0 @@
# 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.
@@ -1,36 +0,0 @@
# 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.
-994
View File
@@ -1,994 +0,0 @@
---
# === 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.
-354
View File
@@ -1,354 +0,0 @@
---
# === 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 |
@@ -1,307 +0,0 @@
# 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.
+1 -141
View File
@@ -30,14 +30,6 @@
"clients": "Répertoire clients",
"suppliers": "Répertoire fournisseurs"
},
"technique": {
"section": "Technique",
"providers": "Répertoire prestataires"
},
"transport": {
"section": "Transport",
"carriers": "Répertoire transporteurs"
},
"core": {
"roles": "Gestion des rôles",
"users": "Utilisateurs",
@@ -370,130 +362,6 @@
}
}
},
"technique": {
"providers": {
"title": "Répertoire prestataires",
"add": "Ajouter",
"export": "Exporter",
"empty": "Aucun prestataire pour l'instant.",
"column": {
"companyName": "Nom",
"categories": "Catégories",
"sites": "Site",
"lastActivity": "Dernière activité"
},
"filters": {
"title": "Filtres",
"search": "Recherche",
"categories": "Catégories",
"sites": "Sites",
"status": "Statut",
"includeArchived": "Inclure les archivés",
"apply": "Voir les résultats",
"reset": "Réinitialiser"
},
"tab": {
"contact": "Contact",
"contacts": "Contacts",
"address": "Adresse",
"reports": "Rapports",
"exchanges": "Échanges",
"accounting": "Comptabilité"
},
"action": {
"edit": "Modifier",
"archive": "Archiver",
"restore": "Restaurer"
},
"consultation": {
"title": "Fiche prestataire",
"back": "Retour au répertoire",
"loading": "Chargement…",
"notFound": "Prestataire introuvable.",
"confirmArchive": "Archiver ce prestataire ? Il n'apparaîtra plus dans le répertoire actif.",
"confirmRestore": "Restaurer ce prestataire ? Il réapparaîtra dans le répertoire actif."
},
"edit": {
"title": "Modifier le prestataire",
"back": "Retour à la fiche",
"loading": "Chargement…",
"notFound": "Prestataire introuvable.",
"save": "Enregistrer"
},
"form": {
"title": "Ajouter un prestataire",
"back": "Précédent",
"submit": "Valider",
"duplicateCompany": "Un prestataire portant ce nom de société existe déjà.",
"main": {
"companyName": "Nom du prestataire (Entreprise)",
"categories": "Catégorie",
"sites": "Site"
},
"errors": {
"nameRequired": "Le nom du prestataire est obligatoire.",
"siteRequired": "Sélectionnez au moins un site.",
"categoryRequired": "Sélectionnez au moins une catégorie."
},
"contact": {
"lastName": "Nom",
"firstName": "Prénom",
"jobTitle": "Fonction",
"email": "Email",
"phonePrimary": "Téléphone",
"phoneSecondary": "Téléphone (2)",
"addPhone": "Ajouter un numéro",
"remove": "Supprimer le contact",
"add": "Nouveau contact"
},
"address": {
"sites": "Sites",
"categories": "Catégorie",
"contacts": "Contact(s) rattaché(s)",
"country": "Pays",
"postalCode": "Code postal",
"city": "Ville",
"street": "Adresse",
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
"streetComplement": "Adresse complémentaire",
"remove": "Supprimer l'adresse",
"add": "Nouvelle adresse",
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
},
"accounting": {
"siren": "SIREN",
"accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA",
"nTva": "N° de TVA",
"paymentDelay": "Délai de règlement",
"paymentType": "Type de règlement",
"bank": "Banque",
"ribLabel": "Libellé",
"ribBic": "BIC",
"ribIban": "IBAN",
"addRib": "Ajouter un RIB",
"removeRib": "Supprimer le RIB"
},
"confirmDelete": {
"title": "Confirmer la suppression",
"cancel": "Annuler",
"confirm": "Supprimer",
"contact": "Supprimer ce contact ?",
"address": "Supprimer cette adresse ?",
"rib": "Supprimer ce RIB ?"
}
},
"toast": {
"error": "Une erreur est survenue. Réessayez.",
"exportError": "L'export du répertoire prestataires a échoué. Réessayez.",
"createSuccess": "Prestataire créé avec succès",
"updateSuccess": "Prestataire mis à jour avec succès",
"addComplete": "Prestataire ajouté",
"archiveSuccess": "Prestataire archivé avec succès",
"restoreSuccess": "Prestataire restauré avec succès"
}
}
},
"auth": {
"login": "Connexion",
"logout": "Deconnexion",
@@ -548,15 +416,7 @@
"commercial_supplier": "Fournisseur",
"commercial_supplieraddress": "Adresse fournisseur",
"commercial_suppliercontact": "Contact fournisseur",
"commercial_supplierrib": "RIB fournisseur",
"technique_provider": "Prestataire",
"technique_provideraddress": "Adresse prestataire",
"technique_providercontact": "Contact prestataire",
"technique_providerrib": "RIB prestataire",
"transport_carrier": "Transporteur",
"transport_carrieraddress": "Adresse transporteur",
"transport_carriercontact": "Contact transporteur",
"transport_carrierprice": "Prix transporteur"
"commercial_supplierrib": "RIB fournisseur"
},
"empty": "Aucune activité enregistrée",
"no_results": "Aucun résultat pour ces filtres",
@@ -157,16 +157,12 @@
<!-- Onglet Contact -->
<template #contact>
<div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ClientContactBlock
v-for="(contact, index) in contacts"
:key="contact.id ?? `new-${index}`"
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:removable="contacts.length > 1"
:readonly="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -203,7 +199,7 @@
:site-options="siteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="isRowRemovable(addresses, index)"
:removable="addresses.length > 1"
:readonly="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@@ -308,7 +304,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
v-if="!accountingReadonly && visibleRibs.length > 1"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -444,7 +440,6 @@ import {
type RibFormDraft,
} from '~/modules/commercial/types/clientForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur).
@@ -495,6 +490,10 @@ const contacts = ref<ContactFormDraft[]>([])
const addresses = ref<AddressFormDraft[]>([])
const ribs = ref<RibFormDraft[]>([])
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
const removedContactIds = ref<number[]>([])
const removedAddressIds = ref<number[]>([])
const removedRibIds = ref<number[]>([])
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
@@ -755,31 +754,32 @@ function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact())
}
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
// local. Echec serveur : bloc conserve + erreur remontee.
function askRemoveContact(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => removeCollectionRow({
rows: contacts.value,
errors: contactErrors.value,
index,
endpoint: '/client_contacts',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyContact,
onError: showError,
}))
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
const removed = contacts.value[index]
if (removed?.id != null) removedContactIds.value.push(removed.id)
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
})
}
/**
* Valide l'onglet Contact : POST/PATCH des blocs restants sur la sous-ressource.
* Strictement scope a la collection contacts (endpoints client_contact dedies). La
* suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
* Valide l'onglet Contact : DELETE des contacts retires (existants), puis
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
* collection contacts (endpoints client_contact dedies).
*/
async function submitContacts(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
contactErrors.value = []
try {
for (const id of removedContactIds.value) {
await api.delete(`/client_contacts/${id}`, {}, { toast: false })
}
removedContactIds.value = []
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides (ex. tous les contacts existants supprimes), on ne
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
@@ -836,15 +836,14 @@ function addAddress(): void {
}
function askRemoveAddress(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => removeCollectionRow({
rows: addresses.value,
errors: addressErrors.value,
index,
endpoint: '/client_addresses',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyAddress,
onError: showError,
}))
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
const removed = addresses.value[index]
if (removed?.id != null) removedAddressIds.value.push(removed.id)
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
})
}
function onAddressDegraded(): void {
@@ -856,12 +855,17 @@ function onAddressDegraded(): void {
})
}
/** Valide l'onglet Adresse : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
async function submitAddresses(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
addressErrors.value = []
try {
for (const id of removedAddressIds.value) {
await api.delete(`/client_addresses/${id}`, {}, { toast: false })
}
removedAddressIds.value = []
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
const hasError = await submitRows(
addresses.value,
@@ -933,32 +937,29 @@ function addRib(): void {
if (canAddRib.value) ribs.value.push(emptyRib())
}
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
// d'une LCR (RG-1.13) -> 409 remonte via showError (message back), bloc conserve.
function askRemoveRib(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => removeCollectionRow({
rows: ribs.value,
errors: ribErrors.value,
index,
endpoint: '/client_ribs',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyRib,
onError: showError,
}))
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
const removed = ribs.value[index]
if (removed?.id != null) removedRibIds.value.push(removed.id)
ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
}
/**
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
* back). Les RIB crees d'abord : le back valide RG-1.13 (LCR => au moins un RIB
* persiste) sur le PATCH scalaires.
* back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
*
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
* plus de DELETE differe ici.
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR hors-LCR ce sont des
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-1.28 :
* sinon 403 sur tout le payload).
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement
* de type de reglement. Aucun champ main/information dans le payload (mode strict
* RG-1.28 : sinon 403 sur tout le payload).
*/
async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return
@@ -1012,6 +1013,14 @@ async function submitAccounting(): Promise<void> {
return
}
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (e) {
@@ -156,16 +156,12 @@
<!-- Onglet Contact -->
<template #contact>
<div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ClientContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:removable="index > 0"
:readonly="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -202,7 +198,7 @@
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="isRowRemovable(addresses, index)"
:removable="index > 0"
:readonly="isValidated('address')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@@ -307,7 +303,7 @@
>
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
v-if="!accountingReadonly && visibleRibs.length > 1"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -421,7 +417,6 @@ import {
type RibFormDraft,
} from '~/modules/commercial/types/clientForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
@@ -126,16 +126,12 @@
<!-- Onglet Contacts -->
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<SupplierContactBlock
v-for="(contact, index) in contacts"
:key="contact.id ?? `new-${index}`"
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:removable="contacts.length > 1"
:readonly="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -172,7 +168,7 @@
:site-options="siteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="isRowRemovable(addresses, index)"
:removable="addresses.length > 1"
:readonly="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@@ -277,7 +273,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
v-if="!accountingReadonly && visibleRibs.length > 1"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -411,7 +407,6 @@ import {
type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur).
@@ -461,6 +456,10 @@ const contacts = ref<SupplierContactFormDraft[]>([])
const addresses = ref<SupplierAddressFormDraft[]>([])
const ribs = ref<SupplierRibFormDraft[]>([])
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
const removedContactIds = ref<number[]>([])
const removedAddressIds = ref<number[]>([])
const removedRibIds = ref<number[]>([])
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
@@ -654,31 +653,32 @@ function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact())
}
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
// local. Echec serveur : bloc conserve + erreur remontee.
function askRemoveContact(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => removeCollectionRow({
rows: contacts.value,
errors: contactErrors.value,
index,
endpoint: '/supplier_contacts',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyContact,
onError: showError,
}))
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => {
const removed = contacts.value[index]
if (removed?.id != null) removedContactIds.value.push(removed.id)
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
})
}
/**
* Valide l'onglet Contacts : POST/PATCH des blocs restants sur la sous-ressource.
* Strictement scope a la collection contacts (endpoints supplier_contact dedies).
* La suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
* Valide l'onglet Contacts : DELETE des contacts retires (existants), puis
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
* collection contacts (endpoints supplier_contact dedies).
*/
async function submitContacts(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
contactErrors.value = []
try {
for (const id of removedContactIds.value) {
await api.delete(`/supplier_contacts/${id}`, {}, { toast: false })
}
removedContactIds.value = []
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides, on les soumet -> 422 RG-2.04 inline (nom OU prenom).
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
@@ -726,15 +726,14 @@ function addAddress(): void {
}
function askRemoveAddress(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => removeCollectionRow({
rows: addresses.value,
errors: addressErrors.value,
index,
endpoint: '/supplier_addresses',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyAddress,
onError: showError,
}))
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => {
const removed = addresses.value[index]
if (removed?.id != null) removedAddressIds.value.push(removed.id)
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
})
}
function onAddressDegraded(): void {
@@ -746,12 +745,17 @@ function onAddressDegraded(): void {
})
}
/** Valide l'onglet Adresses : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
/** Valide l'onglet Adresses : DELETE des adresses retirees puis POST/PATCH. */
async function submitAddresses(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
addressErrors.value = []
try {
for (const id of removedAddressIds.value) {
await api.delete(`/supplier_addresses/${id}`, {}, { toast: false })
}
removedAddressIds.value = []
const hasError = await submitRows(
addresses.value,
addressErrors,
@@ -822,18 +826,15 @@ function addRib(): void {
if (canAddRib.value) ribs.value.push(emptyRib())
}
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
// d'une LCR (RG-2.08) -> 409 remonte via showError (message back), bloc conserve.
function askRemoveRib(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => removeCollectionRow({
rows: ribs.value,
errors: ribErrors.value,
index,
endpoint: '/supplier_ribs',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyRib,
onError: showError,
}))
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => {
const removed = ribs.value[index]
if (removed?.id != null) removedRibIds.value.push(removed.id)
ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
}
/**
@@ -842,12 +843,11 @@ function askRemoveRib(index: number): void {
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
*
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
* plus de DELETE differe ici.
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR hors-LCR ce sont des
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-2.16 :
* sinon 403 sur tout le payload).
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
*/
async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return
@@ -897,6 +897,14 @@ async function submitAccounting(): Promise<void> {
return
}
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
for (const id of removedRibIds.value) {
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
catch (e) {
@@ -121,16 +121,12 @@
<!-- Onglet Contacts -->
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<SupplierContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:removable="index > 0"
:readonly="isValidated('contacts')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -167,7 +163,7 @@
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="isRowRemovable(addresses, index)"
:removable="index > 0"
:readonly="isValidated('addresses')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@@ -271,7 +267,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
v-if="!accountingReadonly && visibleRibs.length > 1"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -384,7 +380,6 @@ import {
type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
@@ -1,269 +0,0 @@
<template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
@click="$emit('remove')"
/>
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
<MalioSelectCheckbox
:model-value="model.siteIris"
:options="siteOptions"
:label="t('technique.providers.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:required="true"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<!-- Categories de type PRESTATAIRE (>= 1 obligatoire, RG-3.09). -->
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:label="t('technique.providers.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:required="true"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
<MalioSelectCheckbox
:model-value="model.contactIris"
:options="contactOptions"
:label="t('technique.providers.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<MalioSelect
:model-value="model.country"
:options="countryOptions"
:label="t('technique.providers.form.address.country')"
:readonly="readonly"
:required="true"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
:model-value="model.postalCode"
:label="t('technique.providers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:required="true"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
:options="cityOptions"
:label="t('technique.providers.form.address.city')"
:readonly="readonly"
empty-option-label=""
:required="true"
:error="errors?.city"
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/>
<MalioInputText
v-else
:model-value="model.city"
:label="t('technique.providers.form.address.city')"
:readonly="readonly"
:required="true"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('technique.providers.form.address.street')"
:readonly="readonly"
:required="true"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('technique.providers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('technique.providers.form.address.street')"
:readonly="readonly"
:required="true"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<div class="col-span-1">
<MalioInputText
:model-value="model.streetComplement"
:label="t('technique.providers.form.address.streetComplement')"
:readonly="readonly"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
// Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####'
const props = defineProps<{
/** Brouillon de l'adresse (v-model). */
modelValue: ProviderAddressFormDraft
/** Categories autorisees sur une adresse (type PRESTATAIRE). */
categoryOptions: RefOption[]
/** Sites Starseed disponibles. */
siteOptions: RefOption[]
/** Contacts deja saisis, rattachables a l'adresse. */
contactOptions: RefOption[]
/** Pays disponibles (France par defaut). */
countryOptions: RefOption[]
removable?: boolean
readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
'update:modelValue': [value: ProviderAddressFormDraft]
'remove': []
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
'degraded': []
}>()
const { t } = useI18n()
const autocomplete = useAddressAutocomplete()
const model = computed(() => props.modelValue)
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable).
const degraded = ref(false)
let unavailableNotified = false
const banCityOptions = ref<RefOption[]>([])
const banAddressOptions = ref<RefOption[]>([])
// Options ville effectives : on garantit que la ville courante figure toujours
// dans la liste, sinon MalioSelect afficherait un champ vide en lecture seule.
const cityOptions = computed<RefOption[]>(() => {
const current = props.modelValue.city
if (current && !banCityOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banCityOptions.value]
}
return banCityOptions.value
})
// Meme garantie pour le champ Adresse : la rue courante doit toujours figurer
// dans les options, sinon MalioInputAutocomplete laisse le champ vide.
const addressOptions = computed<RefOption[]>(() => {
const current = props.modelValue.street
if (current && !banAddressOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banAddressOptions.value]
}
return banAddressOptions.value
})
const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = []
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
function notifyUnavailable(): void {
if (!unavailableNotified) {
unavailableNotified = true
emit('degraded')
}
}
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville (RG-3.06). */
async function onPostalCodeChange(value: string): Promise<void> {
update('postalCode', value)
const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) {
return
}
try {
const suggestions = await autocomplete.searchCity(digits)
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
degraded.value = false
}
catch {
degraded.value = true
notifyUnavailable()
}
}
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
async function onAddressSearch(query: string): Promise<void> {
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400).
if (query.trim().length < 3) {
banAddressOptions.value = []
return
}
addressLoading.value = true
try {
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
const suggestions = await autocomplete.searchAddress(query, postalCode)
lastAddressSuggestions = suggestions
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
}
catch {
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie.
banAddressOptions.value = []
notifyUnavailable()
}
finally {
addressLoading.value = false
}
}
/** Selection d'une suggestion d'adresse → remplit rue + ville + CP. */
function onAddressSelect(option: { label: string, value: string | number } | null): void {
if (option === null) {
return
}
const suggestion = lastAddressSuggestions.find(s => s.street === option.value)
if (!suggestion) {
update('street', String(option.value))
return
}
emit('update:modelValue', {
...props.modelValue,
street: suggestion.street,
city: suggestion.city,
postalCode: suggestion.postalCode,
})
}
</script>
@@ -1,108 +0,0 @@
<template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc) ou en lecture seule. -->
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }"
@click="$emit('remove')"
/>
<MalioInputText
:model-value="model.lastName"
:label="t('technique.providers.form.contact.lastName')"
:readonly="readonly"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)"
/>
<MalioInputText
:model-value="model.firstName"
:label="t('technique.providers.form.contact.firstName')"
:readonly="readonly"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)"
/>
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
<div class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('technique.providers.form.contact.jobTitle')"
:readonly="readonly"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
</div>
<MalioInputEmail
:model-value="model.email"
:label="t('technique.providers.form.contact.email')"
:readonly="readonly"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
:model-value="model.phonePrimary"
:label="t('technique.providers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('technique.providers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<!-- 2e numero : revele a la demande (max 2 telephones par contact). -->
<MalioInputPhone
v-if="model.hasSecondaryPhone"
:model-value="model.phoneSecondary"
:label="t('technique.providers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div>
</template>
<script setup lang="ts">
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##'
const props = defineProps<{
/** Brouillon du contact (v-model). */
modelValue: ProviderContactFormDraft
/** Affiche l'icone de suppression (1er bloc non supprimable). */
removable?: boolean
/** Bloc en lecture seule (onglet valide). */
readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>()
const emit = defineEmits<{
'update:modelValue': [value: ProviderContactFormDraft]
'remove': []
}>()
const { t } = useI18n()
// Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue)
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof ProviderContactFormDraft>(field: K, value: ProviderContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
/** Revele le 2e numero (max 1 secondaire, le « + » disparait). */
function revealSecondaryPhone(): void {
emit('update:modelValue', { ...props.modelValue, hasSecondaryPhone: true })
}
</script>
@@ -1,157 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
import ProviderAddressBlock from '../ProviderAddressBlock.vue'
// Mocks controlables du composable BAN (hoisted), reutilise tel quel du M1/M2.
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
searchCityMock: vi.fn(),
searchAddressMock: vi.fn(),
}))
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
useAddressAutocomplete: () => ({
searchCity: searchCityMock,
searchAddress: searchAddressMock,
}),
}))
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
// Stub de MalioInputAutocomplete : expose les `value` des options + allowCreate.
const MalioInputAutocompleteStub = defineComponent({
name: 'MalioInputAutocomplete',
props: {
modelValue: { type: [String, Number, null], default: undefined },
options: { type: Array as () => { value: string | number, label: string }[], default: () => [] },
loading: { type: Boolean, default: false },
minSearchLength: { type: Number, default: 0 },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
allowCreate: { type: Boolean, default: false },
},
emits: ['update:modelValue', 'search', 'select'],
setup(props) {
return () => h('div', {
'data-testid': 'addr-autocomplete',
'data-options': JSON.stringify(props.options.map(o => o.value)),
})
},
})
function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<string, string>) {
return mount(ProviderAddressBlock, {
props: {
modelValue: { ...emptyProviderAddress(), ...overrides },
categoryOptions: [],
siteOptions: [],
contactOptions: [],
countryOptions: [],
...(errors ? { errors } : {}),
},
global: {
stubs: {
MalioButtonIcon: true,
MalioSelect: true,
MalioSelectCheckbox: true,
MalioInputText: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
},
},
})
}
describe('ProviderAddressBlock — version simplifiee M3 (pas de type/bennes/triage)', () => {
it('ne rend NI type d\'adresse, NI bennes, NI prestation de triage (difference M2)', () => {
const wrapper = mountBlock()
// Pas de stepper (bennes) ni de case a cocher (triage) dans le bloc M3.
expect(wrapper.find('malio-input-number-stub').exists()).toBe(false)
expect(wrapper.find('malio-checkbox-stub').exists()).toBe(false)
// Aucun select ne porte le label « type d'adresse ».
const hasAddressType = wrapper.findAll('malio-select-stub').some(
el => el.attributes('label') === 'technique.providers.form.address.addressType',
)
expect(hasAddressType).toBe(false)
})
})
describe('ProviderAddressBlock — mapping erreur par champ (ERP-101)', () => {
it('affiche les erreurs serveur sur sites et categories (RG-3.05 / RG-3.09)', () => {
const wrapper = mountBlock({}, {
sites: 'Au moins un site est obligatoire.',
categories: 'Au moins une catégorie est obligatoire.',
})
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites')
const categoriesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.categories')
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
})
it('affiche l\'erreur serveur sur le code postal', () => {
const wrapper = mountBlock({}, { postalCode: 'Code postal invalide.' })
const field = wrapper.findAll('malio-input-text-stub').find(
el => el.attributes('label') === 'technique.providers.form.address.postalCode',
)
expect(field?.attributes('error')).toBe('Code postal invalide.')
})
})
describe('ProviderAddressBlock — autocompletion BAN (RG-3.06)', () => {
beforeEach(() => {
searchCityMock.mockReset()
searchAddressMock.mockReset()
})
it('n\'appelle pas la BAN en deca de 3 caracteres', async () => {
const wrapper = mountBlock()
wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab')
await flushPromises()
expect(searchAddressMock).not.toHaveBeenCalled()
})
it('relance la recherche apres une erreur (pas de bascule definitive)', async () => {
searchAddressMock
.mockRejectedValueOnce(new Error('BAN indisponible'))
.mockResolvedValueOnce([
{ label: '1 rue du Test, Châtellerault', street: '1 rue du Test', postalCode: '86100', city: 'Châtellerault' },
])
const wrapper = mountBlock()
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
auto.vm.$emit('search', 'rue du test')
await flushPromises()
auto.vm.$emit('search', 'rue du teste')
await flushPromises()
expect(searchAddressMock).toHaveBeenCalledTimes(2)
})
it('cas degrade : la BAN echoue -> emet « degraded » une seule fois (RG-3.06)', async () => {
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
const wrapper = mountBlock()
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
auto.vm.$emit('search', 'rue du test')
await flushPromises()
auto.vm.$emit('search', 'rue du teste')
await flushPromises()
expect(wrapper.emitted('degraded')).toHaveLength(1)
})
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
const wrapper = mountBlock()
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
})
it('inclut la rue courante dans les options meme sans recherche BAN', () => {
const wrapper = mountBlock({ street: '1 rue du Test' })
const values = JSON.parse(wrapper.find('[data-testid="addr-autocomplete"]').attributes('data-options') ?? '[]')
expect(values).toContain('1 rue du Test')
})
})
@@ -1,55 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyProviderContact } from '~/modules/technique/types/providerForm'
import ProviderContactBlock from '../ProviderContactBlock.vue'
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
/** Stub d'un champ Malio qui re-expose la prop `error` recue dans un data-* attribut. */
function errorProbe(testid: string) {
return defineComponent({
name: `Probe-${testid}`,
props: {
modelValue: { type: [String, Number, null], default: undefined },
error: { type: String, default: '' },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
},
setup(props) {
return () => h('div', { 'data-testid': testid, 'data-error': props.error })
},
})
}
function mountBlock(errors?: Record<string, string>) {
return mount(ProviderContactBlock, {
props: {
modelValue: emptyProviderContact(),
...(errors ? { errors } : {}),
},
global: {
stubs: {
MalioButtonIcon: true,
MalioInputPhone: true,
MalioInputText: errorProbe('contact-text'),
MalioInputEmail: errorProbe('contact-email'),
},
},
})
}
describe('ProviderContactBlock — mapping erreur par champ (ERP-101)', () => {
it('affiche l\'erreur serveur sur le champ email via la prop errors', () => {
const wrapper = mountBlock({ email: 'L\'adresse email n\'est pas valide.' })
expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('L\'adresse email n\'est pas valide.')
})
it('laisse les champs sans erreur quand errors est absent', () => {
const wrapper = mountBlock()
expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('')
})
})
@@ -1,653 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Tests du workflow « Ajouter un prestataire » (M3 Technique, ERP-141).
*
* `useProviderForm` porte le formulaire principal (Nom + Categorie + Site) et
* l'orchestration des onglets de creation. On verifie ici le CONTRAT propre a la
* creation :
* - RG-3.03 (front) : au moins un site requis ; RG-3.09 : au moins une categorie
* -> POST bloque, erreurs inline, aucun appel reseau.
* - POST /providers (groupe provider:write:main) : payload IRIs + Accept ld+json
* + toast:false ; au succes, verrouillage + bascule sur l'onglet Contact +
* reaffichage du nom normalise.
* - 409 doublon (RG-3.10) -> erreur inline dediee sur companyName.
* - 422 -> mapping inline par champ (propertyPath).
* - Onglets : « Comptabilite » present uniquement avec accounting.view ;
* completeTab deverrouille/avance et signale le dernier onglet.
*/
const mockPost = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
// Permissions comptables pilotables par test (presence/edition de l'onglet Comptabilite).
const permState = vi.hoisted(() => ({ accountingView: false, accountingManage: false }))
vi.stubGlobal('useApi', () => ({
get: vi.fn(),
post: mockPost,
put: vi.fn(),
patch: mockPatch,
delete: vi.fn(),
}))
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useToast', () => ({
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}))
vi.stubGlobal('usePermissions', () => ({
can: (perm: string) => {
if (perm === 'technique.providers.accounting.view') return permState.accountingView
if (perm === 'technique.providers.accounting.manage') return permState.accountingManage
return true
},
}))
const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm')
const { emptyProviderContact, emptyProviderAddress } = await import('~/modules/technique/types/providerForm')
type ProviderForm = ReturnType<typeof useProviderForm>
const SITE_86 = '/api/sites/1'
const CAT_MAINT = '/api/categories/7'
/** Accede a un bloc contact (cast : sous noUncheckedIndexedAccess l'index est optionnel). */
function contactAt(form: ProviderForm, index = 0) {
return form.contacts.value[index] ?? emptyProviderContact()
}
/** Accede a un bloc adresse (idem). */
function addressAt(form: ProviderForm, index = 0) {
return form.addresses.value[index] ?? emptyProviderAddress()
}
describe('useProviderForm', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
permState.accountingView = false
permState.accountingManage = false
})
it('front : formulaire principal vide -> erreurs sur nom + site + categorie, pas de POST', async () => {
const form = useProviderForm()
const created = await form.submitMain()
expect(created).toBe(false)
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
expect(form.mainLocked.value).toBe(false)
})
it('RG-3.03 (front) : un site present sans categorie n\'erre que sur categories', async () => {
const form = useProviderForm()
form.main.companyName = 'Maintenance Pro'
form.main.siteIris = [SITE_86]
await form.submitMain()
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.sites).toBeUndefined()
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
})
it('POST /providers avec IRIs + Accept ld+json, verrouille et bascule sur Contact', async () => {
mockPost.mockResolvedValueOnce({ id: 42, companyName: 'MAINTENANCE PRO' })
const form = useProviderForm()
form.main.companyName = 'Maintenance Pro'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const created = await form.submitMain()
expect(created).toBe(true)
expect(mockPost).toHaveBeenCalledTimes(1)
const [url, body, opts] = mockPost.mock.calls[0] ?? []
expect(url).toBe('/providers')
expect(body).toEqual({
companyName: 'Maintenance Pro',
categories: [CAT_MAINT],
sites: [SITE_86],
})
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(form.providerId.value).toBe(42)
// RG-3.11 : reaffiche le nom normalise (UPPERCASE) renvoye par le serveur.
expect(form.main.companyName).toBe('MAINTENANCE PRO')
expect(form.mainLocked.value).toBe(true)
expect(form.activeTab.value).toBe('contact')
expect(form.unlockedIndex.value).toBe(0)
})
it('front : nom vide/espaces -> erreur inline sur companyName, pas de POST', async () => {
const form = useProviderForm()
form.main.companyName = ' '
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const created = await form.submitMain()
expect(created).toBe(false)
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
})
it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => {
mockPost.mockRejectedValueOnce({ response: { status: 409 } })
const form = useProviderForm()
form.main.companyName = 'Doublon'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const created = await form.submitMain()
expect(created).toBe(false)
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany')
expect(form.mainLocked.value).toBe(false)
})
it('422 : mappe les violations serveur inline par champ', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'sites', message: 'Au moins un site est requis.' }] },
},
})
const form = useProviderForm()
form.main.companyName = 'X'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const created = await form.submitMain()
expect(created).toBe(false)
expect(form.mainErrors.errors.sites).toBe('Au moins un site est requis.')
})
it('onglet Comptabilite : absent sans accounting.view, present avec', () => {
expect(buildProviderCreateTabKeys(false)).toEqual(['contact', 'address'])
expect(buildProviderCreateTabKeys(true)).toEqual(['contact', 'address', 'accounting'])
permState.accountingView = true
const form = useProviderForm()
expect(form.tabKeys.value).toEqual(['contact', 'address', 'accounting'])
})
it('completeTab : deverrouille/avance, et signale le dernier onglet du flux', () => {
const form = useProviderForm()
// Contact -> Adresse (pas le dernier).
expect(form.completeTab('contact')).toBe(false)
expect(form.isValidated('contact')).toBe(true)
expect(form.activeTab.value).toBe('address')
expect(form.unlockedIndex.value).toBe(1)
// Adresse = dernier onglet remplissable (sans accounting.view) -> true.
expect(form.completeTab('address')).toBe(true)
expect(form.isValidated('address')).toBe(true)
})
it('patchProvider : PATCH /providers/{id} en mode strict, no-op avant creation', async () => {
const form = useProviderForm()
await form.patchProvider({ siren: '123456789' })
expect(mockPatch).not.toHaveBeenCalled()
mockPost.mockResolvedValueOnce({ id: 9, companyName: 'ACME' })
form.main.companyName = 'Acme'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
await form.submitMain()
await form.patchProvider({ siren: '123456789' })
expect(mockPatch).toHaveBeenCalledWith('/providers/9', { siren: '123456789' }, { toast: false })
})
})
describe('useProviderForm — onglet Contact (ERP-142)', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
permState.accountingView = false
permState.accountingManage = false
})
/** Place le formulaire en etat « prestataire cree » (onglet Contact accessible). */
function createdForm() {
const form = useProviderForm()
form.providerId.value = 7
return form
}
it('RG-3.04 : « + Nouveau contact » desactive tant que le dernier bloc est vide', () => {
const form = createdForm()
expect(form.canAddContact.value).toBe(false)
// addContact est un no-op tant que le bloc est vide.
form.addContact()
expect(form.contacts.value).toHaveLength(1)
contactAt(form).lastName = 'Doe'
expect(form.canAddContact.value).toBe(true)
form.addContact()
expect(form.contacts.value).toHaveLength(2)
})
it('removeContact retire le bloc et son erreur de ligne', () => {
const form = createdForm()
contactAt(form).lastName = 'Doe'
form.addContact()
form.contactErrors.value = [{}, { lastName: 'x' }]
form.removeContact(1)
expect(form.contacts.value).toHaveLength(1)
expect(form.contactErrors.value).toHaveLength(1)
})
it('submitContacts : POST des nouveaux, capture id + IRI, finalise l\'onglet', async () => {
mockPost.mockResolvedValueOnce({ '@id': '/api/provider_contacts/55', id: 55 })
const form = createdForm()
contactAt(form).lastName = 'Doe'
const ok = await form.submitContacts(vi.fn())
expect(ok).toBe(true)
const [url, body, opts] = mockPost.mock.calls[0] ?? []
expect(url).toBe('/providers/7/contacts')
expect(body).toMatchObject({ lastName: 'Doe' })
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(contactAt(form).id).toBe(55)
expect(contactAt(form).iri).toBe('/api/provider_contacts/55')
expect(form.isValidated('contact')).toBe(true)
})
it('submitContacts : PATCH des contacts existants sur /provider_contacts/{id}', async () => {
mockPatch.mockResolvedValueOnce({})
const form = createdForm()
contactAt(form).id = 55
contactAt(form).lastName = 'Doe'
await form.submitContacts(vi.fn())
expect(mockPost).not.toHaveBeenCalled()
expect(mockPatch).toHaveBeenCalledWith('/provider_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false })
})
it('RG-3.12 : onglet vide -> soumet l\'amorce pour declencher la 422 firstName inline', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] },
},
})
const form = createdForm()
const ok = await form.submitContacts(vi.fn())
expect(ok).toBe(false)
expect(mockPost).toHaveBeenCalledTimes(1)
expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.')
expect(form.isValidated('contact')).toBe(false)
})
it('mappe les erreurs 422 PAR LIGNE (le bloc 2 echoue, le bloc 1 passe)', async () => {
mockPost
.mockResolvedValueOnce({ '@id': '/api/provider_contacts/1', id: 1 })
.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'email', message: 'L\'adresse email n\'est pas valide.' }] },
},
})
const form = createdForm()
contactAt(form).lastName = 'Doe'
form.addContact()
contactAt(form, 1).email = 'invalide'
const ok = await form.submitContacts(vi.fn())
expect(ok).toBe(false)
expect(form.contactErrors.value[0]).toBeUndefined()
expect(form.contactErrors.value[1]?.email).toBe('L\'adresse email n\'est pas valide.')
})
})
describe('useProviderForm — onglet Adresse (ERP-143)', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
permState.accountingView = false
permState.accountingManage = false
})
/** Place le formulaire en etat « prestataire cree » (onglet Adresse accessible). */
function createdForm() {
const form = useProviderForm()
form.providerId.value = 7
return form
}
/** Remplit un bloc adresse valide (site + categorie + scalaires requis). */
function fillValidAddress(form: ProviderForm, index = 0): void {
const a = addressAt(form, index)
a.siteIris = [SITE_86]
a.categoryIris = [CAT_MAINT]
a.postalCode = '86100'
a.city = 'Châtellerault'
a.street = '1 rue du Test'
}
it('RG-3.05 : « + Nouvelle adresse » desactive tant que site + categorie manquent', () => {
const form = createdForm()
expect(form.canAddAddress.value).toBe(false)
// no-op tant que l'adresse n'est pas valide.
form.addAddress()
expect(form.addresses.value).toHaveLength(1)
addressAt(form).siteIris = [SITE_86]
expect(form.canAddAddress.value).toBe(false) // categorie manquante
addressAt(form).categoryIris = [CAT_MAINT]
expect(form.canAddAddress.value).toBe(true)
form.addAddress()
expect(form.addresses.value).toHaveLength(2)
})
it('removeAddress retire le bloc et son erreur de ligne', () => {
const form = createdForm()
fillValidAddress(form)
form.addAddress()
form.addressErrors.value = [{}, { city: 'x' }]
form.removeAddress(1)
expect(form.addresses.value).toHaveLength(1)
expect(form.addressErrors.value).toHaveLength(1)
})
it('submitAddresses : POST des nouvelles, capture l\'id, finalise l\'onglet', async () => {
mockPost.mockResolvedValueOnce({ id: 88 })
const form = createdForm()
fillValidAddress(form)
const ok = await form.submitAddresses(vi.fn())
expect(ok).toBe(true)
const [url, body, opts] = mockPost.mock.calls[0] ?? []
expect(url).toBe('/providers/7/addresses')
expect(body).toMatchObject({ sites: [SITE_86], categories: [CAT_MAINT], city: 'Châtellerault' })
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(addressAt(form).id).toBe(88)
expect(form.isValidated('address')).toBe(true)
})
it('submitAddresses : PATCH des adresses existantes sur /provider_addresses/{id}', async () => {
mockPatch.mockResolvedValueOnce({})
const form = createdForm()
fillValidAddress(form)
addressAt(form).id = 88
await form.submitAddresses(vi.fn())
expect(mockPost).not.toHaveBeenCalled()
expect(mockPatch).toHaveBeenCalledWith('/provider_addresses/88', expect.objectContaining({ sites: [SITE_86] }), { toast: false })
})
it('mappe les erreurs 422 PAR LIGNE et ne finalise pas l\'onglet', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'city', message: 'La ville est obligatoire.' }] },
},
})
const form = createdForm()
fillValidAddress(form)
const ok = await form.submitAddresses(vi.fn())
expect(ok).toBe(false)
expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire.')
expect(form.isValidated('address')).toBe(false)
})
})
describe('useProviderForm — onglet Comptabilite (ERP-144)', () => {
const TVA = '/api/tva_modes/1'
const DELAY = '/api/payment_delays/1'
const TYPE = '/api/payment_types/3'
const BANK = '/api/banks/2'
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
permState.accountingView = true
permState.accountingManage = true
})
/** Prestataire cree, onglet Comptabilite editable (view + manage). */
function createdForm() {
const form = useProviderForm()
form.providerId.value = 7
return form
}
/** Remplit les scalaires comptables communs. */
function fillScalars(form: ProviderForm): void {
form.accounting.siren = '123456789'
form.accounting.accountNumber = '4010'
form.accounting.tvaModeIri = TVA
form.accounting.nTva = 'FR123'
form.accounting.paymentDelayIri = DELAY
form.accounting.paymentTypeIri = TYPE
}
it('lecture seule sans accounting.manage (Compta consultation / autres roles)', () => {
permState.accountingManage = false
const form = createdForm()
expect(form.accountingReadonly.value).toBe(true)
permState.accountingManage = true
const form2 = createdForm()
expect(form2.accountingReadonly.value).toBe(false)
})
it('RG-3.07 : setPaymentType(VIREMENT) garde la banque ; un autre type la vide', () => {
const form = createdForm()
form.accounting.bankIri = BANK
// Type VIREMENT -> banque requise, conservee.
form.setPaymentType(TYPE, true, false)
expect(form.accounting.bankIri).toBe(BANK)
// Type non-VIREMENT -> banque videe (sans objet).
form.setPaymentType(TYPE, false, false)
expect(form.accounting.bankIri).toBeNull()
})
it('RG-3.08 : setPaymentType(LCR) garantit au moins un bloc RIB', () => {
const form = createdForm()
expect(form.ribs.value).toHaveLength(0)
form.setPaymentType(TYPE, false, true)
expect(form.ribs.value).toHaveLength(1)
})
it('« + RIB » desactive tant que le dernier RIB est incomplet (RG-3.08)', () => {
const form = createdForm()
form.setPaymentType(TYPE, false, true)
expect(form.canAddRib.value).toBe(false)
const rib = form.ribs.value[0]
if (rib) {
rib.label = 'Compte'
rib.bic = 'BNPAFRPP'
rib.iban = 'FR76...'
}
expect(form.canAddRib.value).toBe(true)
})
it('VIREMENT : PATCH des scalaires avec banque, aucun appel RIB', async () => {
mockPatch.mockResolvedValueOnce({})
const form = createdForm()
fillScalars(form)
form.accounting.bankIri = BANK
const ok = await form.submitAccounting(true, false, vi.fn())
expect(ok).toBe(true)
expect(mockPost).not.toHaveBeenCalled()
expect(mockPatch).toHaveBeenCalledWith(
'/providers/7',
expect.objectContaining({ paymentType: TYPE, bank: BANK, siren: '123456789' }),
{ toast: false },
)
expect(form.isValidated('accounting')).toBe(true)
})
it('hors VIREMENT : la banque part a null dans le payload (RG-3.07)', async () => {
mockPatch.mockResolvedValueOnce({})
const form = createdForm()
fillScalars(form)
form.accounting.bankIri = BANK // residu : doit etre ignore (isBankRequired=false)
await form.submitAccounting(false, false, vi.fn())
const body = mockPatch.mock.calls[0]?.[1] as Record<string, unknown>
expect(body.bank).toBeNull()
})
it('LCR : POST des RIB AVANT le PATCH des scalaires (ordre RG-3.08)', async () => {
mockPost.mockResolvedValueOnce({ id: 50 })
mockPatch.mockResolvedValueOnce({})
const form = createdForm()
fillScalars(form)
form.setPaymentType(TYPE, false, true)
const rib = form.ribs.value[0]
if (rib) {
rib.label = 'Compte'
rib.bic = 'BNPAFRPP'
rib.iban = 'FR76...'
}
const ok = await form.submitAccounting(false, true, vi.fn())
expect(ok).toBe(true)
expect(mockPost).toHaveBeenCalledWith(
'/providers/7/ribs',
expect.objectContaining({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }),
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
expect(form.ribs.value[0]?.id).toBe(50)
// Le PATCH des scalaires intervient APRES la creation du RIB.
expect(mockPatch).toHaveBeenCalledWith('/providers/7', expect.any(Object), { toast: false })
})
it('422 sur les scalaires (bank) : mapping inline, onglet non finalise', async () => {
mockPatch.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'bank', message: 'La banque est obligatoire pour un virement.' }] },
},
})
const form = createdForm()
fillScalars(form)
const ok = await form.submitAccounting(true, false, vi.fn())
expect(ok).toBe(false)
expect(form.accountingErrors.errors.bank).toBe('La banque est obligatoire pour un virement.')
expect(form.isValidated('accounting')).toBe(false)
})
it('LCR : 422 RIB par ligne -> pas de PATCH des scalaires', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'iban', message: 'L\'IBAN est obligatoire.' }] },
},
})
const form = createdForm()
fillScalars(form)
form.setPaymentType(TYPE, false, true)
const rib = form.ribs.value[0]
if (rib) {
rib.label = 'Compte'
rib.bic = 'BNPAFRPP'
}
const ok = await form.submitAccounting(false, true, vi.fn())
expect(ok).toBe(false)
expect(form.ribErrors.value[0]?.iban).toBe('L\'IBAN est obligatoire.')
expect(mockPatch).not.toHaveBeenCalled()
})
})
describe('useProviderForm — modification (ERP-145)', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
permState.accountingView = false
permState.accountingManage = false
})
it('editMode : completeTab ne verrouille pas et ne bascule pas d\'onglet', () => {
const form = useProviderForm()
form.editMode.value = true
form.activeTab.value = 'contact'
expect(form.completeTab('contact')).toBe(false)
expect(form.isValidated('contact')).toBe(false)
expect(form.activeTab.value).toBe('contact')
})
it('updateMain : PATCH /providers/{id} sur le groupe principal (pas de POST)', async () => {
mockPatch.mockResolvedValueOnce({ id: 7, companyName: 'MAINTENANCE PRO' })
const form = useProviderForm()
form.providerId.value = 7
form.main.companyName = 'Maintenance Pro'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const ok = await form.updateMain()
expect(ok).toBe(true)
expect(mockPost).not.toHaveBeenCalled()
expect(mockPatch).toHaveBeenCalledWith(
'/providers/7',
{ companyName: 'Maintenance Pro', categories: [CAT_MAINT], sites: [SITE_86] },
{ toast: false },
)
// Reaffiche le nom normalise renvoye par le serveur.
expect(form.main.companyName).toBe('MAINTENANCE PRO')
})
it('updateMain : RG-3.03 front -> bloque le PATCH sans site', async () => {
const form = useProviderForm()
form.providerId.value = 7
form.main.companyName = 'X'
form.main.categoryIris = [CAT_MAINT]
const ok = await form.updateMain()
expect(ok).toBe(false)
expect(mockPatch).not.toHaveBeenCalled()
expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
})
it('updateMain : 409 doublon -> erreur inline sur companyName', async () => {
mockPatch.mockRejectedValueOnce({ response: { status: 409 } })
const form = useProviderForm()
form.providerId.value = 7
form.main.companyName = 'Doublon'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const ok = await form.updateMain()
expect(ok).toBe(false)
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany')
})
})
@@ -1,78 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useProvidersRepository, type Provider } from '../useProvidersRepository'
const mockApiGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
/**
* Tests du repertoire prestataires (ERP-140).
*
* `useProvidersRepository` est une fine enveloppe de `usePaginatedList<Provider>`
* sur `/providers`. Les invariants generiques de pagination sont deja couverts
* par `usePaginatedList.test.ts` ; on verifie ici le CONTRAT propre au repertoire :
* - la ressource ciblee est bien `/providers`
* - l'enveloppe Hydra (member / totalItems) est consommee
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
* renvoie un tableau plat sans pagination)
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `includeArchived` n'est envoye
* tant que l'utilisateur ne coche pas le filtre (le back masque alors les
* archives) ; le filtre `includeArchived` est bien transmis une fois applique.
*/
describe('useProvidersRepository', () => {
beforeEach(() => {
mockApiGet.mockReset()
})
/** Une page de prestataires Hydra, avec categories[] et sites[] embarques. */
const PAGE: Provider[] = [
{
id: 1,
companyName: 'ACME MAINTENANCE',
categories: [{ code: 'MAINTENANCE_INDUSTRIELLE', name: 'Maintenance industrielle' }],
sites: [{ id: 4, name: 'Chatellerault', color: '#056CF2' }],
updatedAt: '2026-06-15T08:12:01+02:00',
isArchived: false,
},
]
it('cible /providers, consomme l\'enveloppe Hydra et envoie l\'Accept ld+json', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useProvidersRepository()
await repo.fetch()
expect(mockApiGet).toHaveBeenCalledTimes(1)
const [url, query, opts] = mockApiGet.mock.calls[0]
expect(url).toBe('/providers')
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
expect(opts).toMatchObject({
toast: false,
headers: { Accept: 'application/ld+json' },
})
expect(repo.items.value).toEqual(PAGE)
expect(repo.totalItems.value).toBe(1)
})
it('exclut les archives par defaut : aucun includeArchived au premier fetch', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useProvidersRepository()
await repo.fetch()
const query = mockApiGet.mock.calls[0][1] as Record<string, unknown>
expect(query.includeArchived).toBeUndefined()
})
it('transmet includeArchived une fois le filtre applique (retour page 1)', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useProvidersRepository()
await repo.fetch()
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
await repo.setFilters({ includeArchived: true })
expect(repo.currentPage.value).toBe(1)
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(query.includeArchived).toBe(true)
})
})
@@ -1,70 +0,0 @@
import { ref } from 'vue'
import type { ProviderDetail } from '~/modules/technique/utils/forms/providerDetail'
/**
* Chargement et actions d'archivage d'un prestataire unique (ecrans Consultation /
* Modification, ERP-145). Miroir de `useSupplier` (M2). Lit le detail embarque via
* `GET /api/providers/{id}` (contacts / adresses + leurs sous-collections / ribs
* sous `provider:item:read` / `provider:read:accounting`) une SEULE requete
* peuple les deux ecrans (embed borne, pas de N+1).
*
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload Hydra
* complet (avec les `@id` des relations embarquees, indispensables au pre-remplissage).
*
* Etat 100 % local a l'instance (refs). Les erreurs d'archivage / restauration
* (notamment le 409 d'homonyme actif a la restauration) sont PROPAGEES a l'appelant,
* qui decide du toast a afficher.
*/
export function useProvider(id: number | string) {
const api = useApi()
const provider = ref<ProviderDetail | null>(null)
const loading = ref(false)
const error = ref(false)
/** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
function fetchDetail(): Promise<ProviderDetail> {
return api.get<ProviderDetail>(
`/providers/${id}`,
{},
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
}
/** Charge le detail du prestataire. En cas d'echec : `error = true`, `provider = null`. */
async function load(): Promise<void> {
loading.value = true
error.value = false
try {
provider.value = await fetchDetail()
}
catch {
error.value = true
provider.value = null
}
finally {
loading.value = false
}
}
/**
* Bascule l'archivage (PATCH `isArchived` SEUL groupe provider:write:archive ;
* tout autre champ => 422), puis RECHARGE le detail complet : la reponse du PATCH
* ne porte que `provider:read` (ni l'embed des sous-collections ni les libelles
* comptables), un simple merge laisserait l'affichage incoherent. Toute erreur est
* propagee a l'appelant AVANT le rechargement.
*/
async function setArchived(isArchived: boolean): Promise<void> {
await api.patch(`/providers/${id}`, { isArchived }, { toast: false })
provider.value = await fetchDetail()
}
return {
provider,
loading,
error,
load,
archive: () => setArchived(true),
restore: () => setArchived(false),
}
}
@@ -1,645 +0,0 @@
import { computed, reactive, ref, type Ref } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
import { removeCollectionRow } from '~/shared/utils/collectionRow'
import {
emptyProviderAccounting,
emptyProviderAddress,
emptyProviderContact,
emptyProviderMain,
emptyProviderRib,
type ProviderAccountingDraft,
type ProviderAddressFormDraft,
type ProviderAddressResponse,
type ProviderContactFormDraft,
type ProviderContactResponse,
type ProviderMainDraft,
type ProviderMainResponse,
type ProviderRibFormDraft,
type ProviderRibResponse,
} from '~/modules/technique/types/providerForm'
import {
buildProviderContactPayload,
isProviderContactBlank,
isProviderContactNamed,
} from '~/modules/technique/utils/forms/providerContact'
import {
buildProviderAddressPayload,
isProviderAddressValid,
} from '~/modules/technique/utils/forms/providerAddress'
import {
buildProviderAccountingPayload,
buildProviderRibPayload,
isRibBlank,
isRibComplete,
} from '~/modules/technique/utils/forms/providerAccounting'
/**
* Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141)
* miroir conceptuel de la logique de creation fournisseur (M2), extraite ici en
* composable.
*
* Particularites M3 (cf. spec-front § « Ecran Ajouter ») :
* - PAS d'onglet « Information » : le formulaire principal est minimal (Nom +
* Categorie + Site).
* - Selecteur de site SUR le formulaire principal (RG-3.03, relation directe
* `provider.sites`).
* - Creation incrementale par onglets (Contact · Adresse · Comptabilite) :
* POST principal puis PATCH partiels par groupe de serialisation
* (`provider:write:*`, mode strict spec-back § 2.10). Le contenu des onglets
* arrive aux tickets ERP-142 144 ; ce composable pose le POST principal et
* l'orchestration des onglets.
*
* Etat 100 % local a l'instance (refs/reactive) aucune persistance URL.
*/
/**
* Cles des onglets du FLUX DE CREATION. Pas d'onglet « Information » au M3 ;
* « Rapports » / « Echanges » n'apparaissent qu'en consultation/modification.
* L'onglet « Comptabilite » n'est present que pour les roles qui peuvent le voir
* (`technique.providers.accounting.view` Admin, Compta).
*/
export function buildProviderCreateTabKeys(canAccountingView: boolean): string[] {
return canAccountingView
? ['contact', 'address', 'accounting']
: ['contact', 'address']
}
export function useProviderForm() {
const api = useApi()
const { t } = useI18n()
const toast = useToast()
const { can } = usePermissions()
// Erreurs de validation par champ (ERP-101) du formulaire principal.
const mainErrors = useFormErrors()
// ERP-172 : remontee d'erreur 409/422 lors d'une suppression immediate de
// sous-ressource (message back affiche en toast dedie — pas de mapping inline,
// le bloc est en cours de retrait). Ex. dernier RIB d'une LCR -> 409.
function notifyRemovalError(error: unknown): void {
toast.error({
title: t('technique.providers.toast.error'),
message: extractApiErrorMessage((error as { data?: unknown })?.data) || t('technique.providers.toast.error'),
})
}
// ── Etat du prestataire cree ────────────────────────────────────────────
const providerId = ref<number | null>(null)
const mainLocked = ref(false)
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
// ── Formulaire principal ──────────────────────────────────────────────────
const main = reactive<ProviderMainDraft>(emptyProviderMain())
// ── Onglets : ordre + gating progressif ───────────────────────────────────
const canAccountingView = computed(() => can('technique.providers.accounting.view'))
const canAccountingManage = computed(() => can('technique.providers.accounting.manage'))
const tabKeys = computed(() => buildProviderCreateTabKeys(canAccountingView.value))
// Index du dernier onglet deverrouille (-1 tant que le prestataire n'est pas cree).
const unlockedIndex = ref(-1)
const activeTab = ref<string>('contact')
// Onglets valides (passent en lecture seule).
const validated = reactive<Record<string, boolean>>({})
// Mode MODIFICATION (ERP-145) : navigation libre, pas de verrouillage ni de
// bascule automatique d'onglet a la validation (cf. completeTab).
const editMode = ref(false)
function isValidated(key: string): boolean {
return validated[key] === true
}
function tabIndex(key: string): number {
return tabKeys.value.indexOf(key)
}
/**
* Validation FRONT du formulaire principal : RG-3.03 (>= 1 site) et RG-3.09
* (>= 1 categorie). Pose les erreurs inline et retourne false si invalide.
* Le back reste la couche autoritaire (ERP-101) ; ce pre-check evite un
* aller-retour inutile et porte la garantie RG-3.03 cote front.
*/
function validateMainFront(): boolean {
let valid = true
if (!main.companyName?.trim()) {
mainErrors.setError('companyName', t('technique.providers.form.errors.nameRequired'))
valid = false
}
if (main.siteIris.length === 0) {
mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired'))
valid = false
}
if (main.categoryIris.length === 0) {
mainErrors.setError('categories', t('technique.providers.form.errors.categoryRequired'))
valid = false
}
return valid
}
/**
* Payload du POST principal (groupe `provider:write:main`). `companyName` est
* omis s'il est vide afin que la 422 porte la violation NotBlank (RG-3.11) sur
* le champ plutot qu'une erreur de type. Les relations M2M partent en IRI.
*/
function buildMainPayload(): Record<string, unknown> {
const payload: Record<string, unknown> = {
categories: [...main.categoryIris],
sites: [...main.siteIris],
}
if (main.companyName?.trim()) {
payload.companyName = main.companyName
}
return payload
}
/**
* POST /providers (groupe `provider:write:main`). Pre-check front RG-3.03/3.09,
* puis creation. Au succes : verrouille le bloc principal, deverrouille le 1er
* onglet et bascule sur « Contact ». Retourne true si cree, false sinon.
*/
async function submitMain(): Promise<boolean> {
if (mainSubmitting.value) return false
mainErrors.clearErrors()
if (!validateMainFront()) return false
mainSubmitting.value = true
try {
const created = await api.post<ProviderMainResponse>('/providers', buildMainPayload(), {
headers: { Accept: 'application/ld+json' },
toast: false,
})
providerId.value = created.id
// Reaffiche la valeur normalisee renvoyee par le serveur (UPPERCASE, RG-3.11).
main.companyName = created.companyName ?? main.companyName
mainLocked.value = true
unlockedIndex.value = 0
activeTab.value = tabKeys.value[0] ?? 'contact'
toast.success({ title: t('technique.providers.toast.createSuccess') })
return true
}
catch (error) {
// 409 = doublon de nom (RG-3.10) → erreur inline dediee + toast ;
// 422 → mapping inline par champ ; autre → toast de fallback (ERP-101).
const status = (error as { response?: { status?: number } })?.response?.status
if (status === 409) {
const message = t('technique.providers.form.duplicateCompany')
mainErrors.setError('companyName', message)
toast.error({ title: t('technique.providers.toast.error'), message })
}
else {
mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
}
return false
}
finally {
mainSubmitting.value = false
}
}
/**
* PATCH partiel du prestataire (mode strict : un seul groupe de serialisation
* par appel spec-back § 2.10). Sert l'onglet Comptabilite a champs scalaires
* (ERP-144) ; les onglets Contact/Adresse passent par leurs sous-ressources
* (POST/PATCH par ligne, ERP-142/143). No-op tant que le prestataire n'existe pas.
*/
async function patchProvider(payload: Record<string, unknown>): Promise<void> {
if (providerId.value === null) return
await api.patch(`/providers/${providerId.value}`, payload, { toast: false })
}
/**
* MODIFICATION du bloc principal (ERP-145) : PATCH /providers/{id} sur le groupe
* provider:write:main (nom + categories + sites). Pre-check front RG-3.03/3.09,
* 409 doublon de nom (RG-3.10) et 422 mappes inline comme a la creation. A la
* difference de `submitMain`, ne verrouille rien et ne bascule pas d'onglet (la
* navigation est libre en modification). Retourne true si le PATCH a reussi.
*/
async function updateMain(): Promise<boolean> {
if (providerId.value === null || mainSubmitting.value) return false
mainErrors.clearErrors()
if (!validateMainFront()) return false
mainSubmitting.value = true
try {
const updated = await api.patch<ProviderMainResponse>(
`/providers/${providerId.value}`,
buildMainPayload(),
{ toast: false },
)
main.companyName = updated.companyName ?? main.companyName
return true
}
catch (error) {
const status = (error as { response?: { status?: number } })?.response?.status
if (status === 409) {
const message = t('technique.providers.form.duplicateCompany')
mainErrors.setError('companyName', message)
toast.error({ title: t('technique.providers.toast.error'), message })
}
else {
mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
}
return false
}
finally {
mainSubmitting.value = false
}
}
/**
* Marque un onglet valide (passe en lecture seule), deverrouille et avance a
* l'onglet suivant. Retourne true si c'etait le dernier onglet du flux
* (creation terminee), false sinon.
*/
function completeTab(key: string): boolean {
// En modification : navigation libre, l'onglet reste editable apres validation.
if (editMode.value) {
return false
}
validated[key] = true
const index = tabIndex(key)
const next = tabKeys.value[index + 1]
if (next === undefined) {
return true
}
unlockedIndex.value = Math.max(unlockedIndex.value, index + 1)
activeTab.value = next
return false
}
/**
* Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX :
* on n'arrete pas au premier bloc en echec (decision ERP-101). Reinitialise la
* cible, tente chaque ligne via `saveRow`, mappe les 422 inline ou delegue le
* fallback a `onUnmappedError`. `shouldSkip` ignore les amorces vides. Retourne
* true si au moins un bloc a echoue. Miroir de `useSupplierFormErrors.submitRows`.
*/
async function submitRows<T>(
rows: T[],
target: Ref<Record<string, string>[]>,
saveRow: (row: T, index: number) => Promise<void>,
onUnmappedError: (error: unknown, index: number) => void,
shouldSkip?: (row: T, index: number) => boolean,
): Promise<boolean> {
target.value = []
let hasError = false
for (let index = 0; index < rows.length; index++) {
const row = rows[index] as T
if (shouldSkip?.(row, index)) {
continue
}
try {
await saveRow(row, index)
}
catch (error) {
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
if (Object.keys(mapped).length > 0) {
target.value[index] = mapped
}
else {
onUnmappedError(error, index)
}
hasError = true
}
}
return hasError
}
// ── Onglet Contact (ERP-142) ──────────────────────────────────────────────
const contacts = ref<ProviderContactFormDraft[]>([emptyProviderContact()])
// Erreurs 422 par ligne (alignees sur l'index du v-for), peuplees par submitRows.
const contactErrors = ref<Record<string, string>[]>([])
// « + Nouveau contact » desactive tant que le dernier bloc n'a pas de nom OU
// prenom (RG-3.04, aligne M1/M2 — fonction/tel/email seuls ne suffisent pas).
const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last !== undefined && isProviderContactNamed(last)
})
function addContact(): void {
if (canAddContact.value) {
contacts.value.push(emptyProviderContact())
}
}
// ERP-172 : DELETE immediat du contact existant (sous-ressource) a la
// confirmation de la modale. Bloc jamais persiste (id null) : retrait local.
async function removeContact(index: number): Promise<void> {
await removeCollectionRow({
rows: contacts.value,
errors: contactErrors.value,
index,
endpoint: '/provider_contacts',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderContact,
onError: notifyRemovalError,
})
}
/**
* Valide l'onglet Contact : POST des nouveaux contacts sur
* /providers/{id}/contacts, PATCH des existants sur /provider_contacts/{id}
* (sous-ressource, groupe provider:write:contacts). RG-3.12 : au moins un bloc
* valide. Si l'onglet ne contient QUE des amorces vides, on les soumet pour
* declencher la 422 RG-3.04 inline (sur `firstName`) plutot que de finaliser un
* onglet vide. Retourne true si l'onglet a ete valide (avance/termine).
*/
async function submitContacts(onError: (error: unknown) => void): Promise<boolean> {
if (providerId.value === null || tabSubmitting.value) {
return false
}
tabSubmitting.value = true
try {
const hasSubmittable = contacts.value.some(c => c.id !== null || !isProviderContactBlank(c))
const hasError = await submitRows(
contacts.value,
contactErrors,
async (contact) => {
const body = buildProviderContactPayload(contact)
if (contact.id === null) {
const created = await api.post<ProviderContactResponse>(
`/providers/${providerId.value}/contacts`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
contact.id = created.id
contact.iri = created['@id'] ?? null
}
else {
await api.patch(`/provider_contacts/${contact.id}`, body, { toast: false })
}
},
onError,
contact => hasSubmittable && contact.id === null && isProviderContactBlank(contact),
)
if (hasError) {
return false
}
completeTab('contact')
return true
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Adresse (ERP-143) ──────────────────────────────────────────────
const addresses = ref<ProviderAddressFormDraft[]>([emptyProviderAddress()])
// Erreurs 422 par ligne (alignees sur l'index du v-for).
const addressErrors = ref<Record<string, string>[]>([])
// « + Nouvelle adresse » desactive tant que la derniere adresse n'a pas
// au moins un site ET une categorie (RG-3.05 / RG-3.09).
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
return last !== undefined && isProviderAddressValid(last)
})
function addAddress(): void {
if (canAddAddress.value) {
addresses.value.push(emptyProviderAddress())
}
}
// ERP-172 : DELETE immediat de l'adresse existante (sous-ressource).
async function removeAddress(index: number): Promise<void> {
await removeCollectionRow({
rows: addresses.value,
errors: addressErrors.value,
index,
endpoint: '/provider_addresses',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderAddress,
onError: notifyRemovalError,
})
}
/**
* Valide l'onglet Adresse : POST des nouvelles adresses sur
* /providers/{id}/addresses, PATCH des existantes sur /provider_addresses/{id}
* (sous-ressource, groupe provider:write:addresses). Erreurs 422 collectees par
* ligne. Retourne true si l'onglet a ete valide (avance/termine).
*/
async function submitAddresses(onError: (error: unknown) => void): Promise<boolean> {
if (providerId.value === null || tabSubmitting.value) {
return false
}
tabSubmitting.value = true
try {
const hasError = await submitRows(
addresses.value,
addressErrors,
async (address) => {
const body = buildProviderAddressPayload(address)
if (address.id === null) {
const created = await api.post<ProviderAddressResponse>(
`/providers/${providerId.value}/addresses`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
address.id = created.id
}
else {
await api.patch(`/provider_addresses/${address.id}`, body, { toast: false })
}
},
onError,
)
if (hasError) {
return false
}
completeTab('address')
return true
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Comptabilite (ERP-144) ─────────────────────────────────────────
const accounting = reactive<ProviderAccountingDraft>(emptyProviderAccounting())
const ribs = ref<ProviderRibFormDraft[]>([])
const accountingErrors = useFormErrors()
// Erreurs 422 par ligne de RIB (alignees sur l'index du v-for).
const ribErrors = ref<Record<string, string>[]>([])
// L'onglet est editable seulement avec accounting.manage (sinon lecture seule).
const accountingReadonly = computed(() => isValidated('accounting') || !canAccountingManage.value)
/**
* Met a jour le type de reglement (IRI) en propageant ses RG inter-champs :
* - hors VIREMENT (RG-3.07) : on vide la banque (sans objet) ;
* - LCR (RG-3.08) : on garantit au moins un bloc RIB visible ; hors LCR, on
* purge les erreurs de RIB (les blocs sont conserves mais non persistes).
* `isBankRequired` / `isRibRequired` sont calcules par l'appelant (page) a
* partir du code resolu via les referentiels.
*/
function setPaymentType(iri: string | null, isBankRequired: boolean, isRibRequired: boolean): void {
accounting.paymentTypeIri = iri
if (!isBankRequired) {
accounting.bankIri = null
}
if (isRibRequired) {
if (ribs.value.length === 0) {
ribs.value.push(emptyProviderRib())
}
}
else {
ribErrors.value = []
}
}
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet (RG-3.08).
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
return last !== undefined && isRibComplete(last)
})
function addRib(): void {
if (canAddRib.value) {
ribs.value.push(emptyProviderRib())
}
}
// ERP-172 : DELETE immediat du RIB existant. Le back peut refuser la suppression
// du dernier RIB d'une LCR -> 409 remonte via notifyRemovalError, bloc conserve.
async function removeRib(index: number): Promise<void> {
await removeCollectionRow({
rows: ribs.value,
errors: ribErrors.value,
index,
endpoint: '/provider_ribs',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderRib,
onError: notifyRemovalError,
})
}
/**
* Valide l'onglet Comptabilite : (1) sous LCR, POST/PATCH des RIB d'abord
* (le back valide RG-3.08 sur le PATCH scalaires, les RIB doivent donc exister
* AVANT) ; (2) PATCH des scalaires comptables (groupe provider:write:accounting,
* banque envoyee seulement si VIREMENT RG-3.07). Erreurs RIB par ligne ;
* erreurs scalaires inline (bank/paymentType). Retourne true si l'onglet a ete
* valide.
*/
async function submitAccounting(
isBankRequired: boolean,
isRibRequired: boolean,
onRibError: (error: unknown) => void,
): Promise<boolean> {
if (providerId.value === null || tabSubmitting.value) {
return false
}
tabSubmitting.value = true
accountingErrors.clearErrors()
try {
// 1) RIB d'abord, uniquement sous LCR. Une amorce vide neuve est sautee
// s'il reste un autre RIB soumettable ; sinon (LCR sans aucun RIB rempli)
// on la soumet pour declencher la 422 NotBlank inline.
if (isRibRequired) {
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
const ribHasError = await submitRows(
ribs.value,
ribErrors,
async (rib) => {
const body = buildProviderRibPayload(rib)
if (rib.id === null) {
const created = await api.post<ProviderRibResponse>(
`/providers/${providerId.value}/ribs`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/provider_ribs/${rib.id}`, body, { toast: false })
}
},
onRibError,
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
)
if (ribHasError) {
return false
}
}
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(
`/providers/${providerId.value}`,
buildProviderAccountingPayload(accounting, isBankRequired),
{ toast: false },
)
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
return false
}
completeTab('accounting')
return true
}
finally {
tabSubmitting.value = false
}
}
return {
// etat
main,
providerId,
mainLocked,
mainSubmitting,
tabSubmitting,
mainErrors,
// onglets
canAccountingView,
canAccountingManage,
tabKeys,
activeTab,
unlockedIndex,
validated,
editMode,
isValidated,
// contacts
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
// adresses
addresses,
addressErrors,
canAddAddress,
addAddress,
removeAddress,
submitAddresses,
// comptabilite
accounting,
ribs,
accountingErrors,
ribErrors,
accountingReadonly,
setPaymentType,
canAddRib,
addRib,
removeRib,
submitAccounting,
// actions
validateMainFront,
buildMainPayload,
submitMain,
updateMain,
patchProvider,
completeTab,
submitRows,
}
}
@@ -1,136 +0,0 @@
import { ref } from 'vue'
/**
* Charge les referentiels (listes courtes) alimentant les selects du formulaire
* principal de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) :
* categories (type PRESTATAIRE) et sites (86 / 17 / 82).
*
* Miroir reduit de `useSupplierReferentials` (M2) : a ce stade (formulaire
* principal) seuls categories + sites sont necessaires. Les referentiels
* comptables (modes de TVA, delais/types de reglement, banques) seront charges
* par l'onglet Comptabilite (ERP-144).
*
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
* `?pagination=false` (referentiels de quelques entrees), avec l'en-tete
* `Accept: application/ld+json` impose par API Platform 4 pour obtenir l'enveloppe
* Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`), renvoyee telle
* quelle dans le payload POST (relations M2M).
*
* Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole ; un
* echec (permission manquante, reseau) laisse simplement la liste vide.
*
* Etat 100 % local a l'instance (refs) aucune persistance URL.
*/
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
export interface RefOption {
value: string
label: string
}
/** Option de type de reglement enrichie de son code stable (RG-3.07 / RG-3.08). */
export interface PaymentTypeOption extends RefOption {
code: string
}
interface HydraMember {
'@id': string
}
interface ReferentialMember extends HydraMember {
code: string
label: string
}
interface CategoryMember extends HydraMember {
code: string
name: string
}
interface SiteMember extends HydraMember {
name: string
postalCode: string
}
interface CountryMember extends HydraMember {
code: string
name: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useProviderReferentials() {
const api = useApi()
const categories = ref<RefOption[]>([])
const sites = ref<RefOption[]>([])
const countries = ref<RefOption[]>([])
// Referentiels comptables (charges a la demande via loadAccounting).
const tvaModes = ref<RefOption[]>([])
const paymentDelays = ref<RefOption[]>([])
const paymentTypes = ref<PaymentTypeOption[]>([])
const banks = ref<RefOption[]>([])
/** Recupere une collection complete (pagination desactivee) en Hydra. */
async function fetchAll<T extends HydraMember>(
url: string,
query: Record<string, string | string[]> = {},
): Promise<T[]> {
const res = await api.get<{ member?: T[] }>(
url,
{ pagination: 'false', ...query },
{ headers: LD_JSON_HEADERS, toast: false },
)
return res.member ?? []
}
/** Charge en parallele les referentiels du formulaire principal (categories + sites). */
async function loadMain(): Promise<void> {
await Promise.allSettled([
// RG-3.09 : un prestataire ne porte que des categories de type
// PRESTATAIRE -> filtre cote API. Libelle affiche = `name`.
fetchAll<CategoryMember>('/categories', { typeCode: 'PRESTATAIRE' })
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name })) }),
// Sites (RG-3.03) : libelle = numero de departement (2 premiers chiffres
// du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ».
fetchAll<SiteMember>('/sites')
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
// Pays (ERP-116) : la valeur d'option est le NOM du pays (l'adresse stocke
// `country` en chaine libre, « France »...). value === label. Aligne sur
// les ecrans client/fournisseur. Sert le select Pays de l'onglet Adresse.
fetchAll<CountryMember>('/countries')
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
])
}
/**
* Charge les referentiels comptables (onglet Comptabilite, ERP-144). Appele
* uniquement quand l'utilisateur peut voir l'onglet (accounting.view). Resilient
* (allSettled) : un referentiel en echec reste vide.
*/
async function loadAccounting(): Promise<void> {
await Promise.allSettled([
fetchAll<ReferentialMember>('/tva_modes')
.then((list) => { tvaModes.value = list.map(t => ({ value: t['@id'], label: t.label })) }),
fetchAll<ReferentialMember>('/payment_delays')
.then((list) => { paymentDelays.value = list.map(d => ({ value: d['@id'], label: d.label })) }),
// Le code stable du type sert les RG-3.07 (VIREMENT) / RG-3.08 (LCR).
fetchAll<ReferentialMember>('/payment_types')
.then((list) => { paymentTypes.value = list.map(p => ({ value: p['@id'], label: p.label, code: p.code })) }),
fetchAll<ReferentialMember>('/banks')
.then((list) => { banks.value = list.map(b => ({ value: b['@id'], label: b.label })) }),
])
}
return {
categories,
sites,
countries,
tvaModes,
paymentDelays,
paymentTypes,
banks,
loadMain,
loadAccounting,
}
}
@@ -1,62 +0,0 @@
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
/**
* Site Starseed rattache DIRECTEMENT au prestataire (M2M `provider_site`,
* RG-3.03), tel qu'embarque en LISTE (groupe site:read) pour la colonne « Site »
* du Repertoire (badges colores).
*
* Difference M3 vs M2 : au M2 les sites venaient de l'agregat dedoublonne des
* adresses (`Supplier::getSites()`) ; ici c'est une relation directe portee par
* le formulaire principal (cf. spec-back M3 § 2.12).
*/
export interface ProviderSite {
id: number
name: string
color: string
}
/**
* Categorie (type PRESTATAIRE) rattachee au prestataire, embarquee en LISTE
* (groupe category:read). La colonne « Catégories » affiche le `name` (cohérence
* M1/M2 libellé = `name`, pas `code`).
*/
export interface ProviderCategory {
code: string
name: string
}
/**
* Vue MINIMALE d'un prestataire pour le Repertoire (datatable). Volontairement
* partielle : seuls les champs des colonnes + l'id (navigation) sont types ici.
* Le detail complet (onglets) est hors perimetre de cet ecran (ERP-140).
*/
export interface Provider {
id: number
companyName: string
categories: ProviderCategory[]
sites: ProviderSite[]
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
updatedAt: string | null
isArchived: boolean
}
/**
* Repertoire prestataires (ERP-140) simple enveloppe de `usePaginatedList<Provider>`
* sur la ressource `/providers` (pagination serveur obligatoire ; jamais de
* chargement integral en memoire). Miroir de `useSuppliersRepository` (M2).
*
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
* par la page via `setFilters` du composable partage la remise en page 1 est
* garantie. Par defaut, aucun `includeArchived` n'est envoye : le back masque
* donc les prestataires archives (exclusion par defaut, spec-back § 2.11).
*
* Le cloisonnement par site est applique AUTOMATIQUEMENT cote back (§ 2.13) en
* fonction de l'utilisateur rien a filtrer cote front.
*
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
* `usePaginatedList`. Aucun reset au logout a gerer.
*/
export function useProvidersRepository() {
return usePaginatedList<Provider>({ url: '/providers' })
}
@@ -1,543 +0,0 @@
<template>
<div>
<!-- En-tete : retour consultation + nom du prestataire. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('technique.providers.edit.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('technique.providers.edit.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('technique.providers.edit.notFound') }}</p>
<template v-else-if="provider">
<!-- Bloc principal (pre-rempli, editable si `manage`) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:label="t('technique.providers.form.main.companyName')"
:required="true"
:readonly="businessReadonly"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:label="t('technique.providers.form.main.categories')"
:display-tag="true"
:readonly="businessReadonly"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
<MalioSelectCheckbox
:model-value="main.siteIris"
:options="referentials.sites.value"
:label="t('technique.providers.form.main.sites')"
:display-tag="true"
:readonly="businessReadonly"
:required="true"
:error="mainErrors.errors.sites"
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
/>
</div>
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('technique.providers.edit.save')"
:disabled="mainSubmitting"
@click="onUpdateMain"
/>
</div>
<!-- Onglets : navigation LIBRE, edition independante par onglet -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Contact -->
<template #contact>
<div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ProviderContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:removable="isRowRemovable(contacts, index)"
:readonly="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('technique.providers.form.contact.add')"
:disabled="!canAddContact"
@click="addContact"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.edit.save')"
:disabled="tabSubmitting"
@click="onSubmitContacts"
/>
</div>
</div>
</template>
<!-- Onglet Adresse -->
<template #address>
<div class="mt-12 flex flex-col gap-6">
<ProviderAddressBlock
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:category-options="referentials.categories.value"
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="isRowRemovable(addresses, index)"
:readonly="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('technique.providers.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.edit.save')"
:disabled="tabSubmitting"
@click="onSubmitAddresses"
/>
</div>
</div>
</template>
<!-- Onglet Comptabilite (present si accounting.view ; editable si manage). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:label="t('technique.providers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value"
:label="t('technique.providers.form.accounting.tvaMode')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:label="t('technique.providers.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value"
:label="t('technique.providers.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value"
:label="t('technique.providers.form.accounting.paymentType')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange"
/>
<MalioSelect
v-if="isBankRequired"
:model-value="accounting.bankIri"
:options="referentials.banks.value"
:label="t('technique.providers.form.accounting.bank')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/>
</div>
</div>
<!-- Blocs RIB affiches uniquement si type de reglement = LCR (RG-3.08). -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:label="t('technique.providers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:label="t('technique.providers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.iban"
/>
</div>
</div>
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
<MalioButton
v-if="isRibRequired"
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('technique.providers.form.accounting.addRib')"
:disabled="!canAddRib"
@click="addRib"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.edit.save')"
:disabled="tabSubmitting"
@click="onSubmitAccounting"
/>
</div>
</div>
</template>
</MalioTabList>
</template>
<!-- Modal de confirmation generique (suppression contact / adresse / RIB). -->
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2>
</template>
<p>{{ confirmModal.message }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('technique.providers.form.confirmDelete.cancel')"
@click="confirmModal.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="t('technique.providers.form.confirmDelete.confirm')"
@click="runConfirm"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useProvider } from '~/modules/technique/composables/useProvider'
import { useProviderReferentials, type RefOption } from '~/modules/technique/composables/useProviderReferentials'
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
import {
canEditProvider,
irisOf,
mapAccountingDraft,
mapAddressToDraft,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
} from '~/modules/technique/utils/forms/providerDetail'
import {
isBankRequiredForPaymentType,
isRibRequiredForPaymentType,
} from '~/modules/technique/utils/forms/providerAccounting'
import {
emptyProviderAddress,
emptyProviderContact,
emptyProviderRib,
} from '~/modules/technique/types/providerForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { can, canAny } = usePermissions()
const providerId = route.params.id as string
// Acces : l'edition exige `manage` OU `accounting.manage` (le role Compta edite
// son onglet). Sinon retour consultation.
if (!canEditProvider(canAny)) {
await navigateTo(`/providers/${providerId}`)
}
const businessReadonly = computed(() => !can('technique.providers.manage'))
const referentials = useProviderReferentials()
const { provider, loading, error, load } = useProvider(providerId)
const {
main,
providerId: formProviderId,
mainErrors,
mainSubmitting,
tabSubmitting,
editMode,
canAccountingView,
tabKeys,
activeTab,
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
addresses,
addressErrors,
canAddAddress,
addAddress,
removeAddress,
submitAddresses,
accounting,
ribs,
accountingErrors,
ribErrors,
accountingReadonly,
setPaymentType,
canAddRib,
addRib,
removeRib,
submitAccounting,
updateMain,
} = useProviderForm()
// Modification : navigation libre + pas de verrouillage a la validation.
editMode.value = true
activeTab.value = 'contact'
const headerTitle = computed(() => provider.value?.companyName || t('technique.providers.edit.title'))
useHead({ title: t('technique.providers.edit.title') })
// Onglets (navigation libre ; Comptabilite si accounting.view)
const TAB_ICONS: Record<string, string> = {
contact: 'mdi:account-box-plus-outline',
address: 'mdi:map-marker-outline',
accounting: 'mdi:bank-circle-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
key,
label: t(`technique.providers.tab.${key}`),
icon: TAB_ICONS[key],
})))
/** Pre-remplit les brouillons depuis la SEULE reponse detail. */
function prefill(): void {
const d = provider.value
if (!d) return
// Indispensable : pilote les URLs des PATCH/POST par onglet (sinon les submits no-op).
formProviderId.value = d.id
main.companyName = d.companyName ?? null
main.categoryIris = irisOf(d.categories)
main.siteIris = irisOf(d.sites)
const mappedContacts = (d.contacts ?? []).map(mapContactToDraft)
contacts.value = mappedContacts.length > 0 ? mappedContacts : [emptyProviderContact()]
const mappedAddresses = (d.addresses ?? []).map(mapAddressToDraft)
addresses.value = mappedAddresses.length > 0 ? mappedAddresses : [emptyProviderAddress()]
if (canAccountingView.value) {
Object.assign(accounting, mapAccountingDraft(d))
ribs.value = (d.ribs ?? []).map(mapRibToDraft)
// Garantit un bloc RIB visible si le type de reglement est LCR.
if (isRibRequiredForPaymentType(paymentTypeCodeOf(d.paymentType)) && ribs.value.length === 0) {
ribs.value.push(emptyProviderRib())
}
}
}
// Comptabilite : RG-3.07 / RG-3.08 pilotees par le code du type de reglement
const selectedPaymentTypeCode = computed(() =>
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
)
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void {
const iri = value === null ? null : String(value)
const code = referentials.paymentTypes.value.find(p => p.value === iri)?.code ?? null
setPaymentType(iri, isBankRequiredForPaymentType(code), isRibRequiredForPaymentType(code))
}
// Options adresses
const contactOptions = computed<RefOption[]>(() =>
contacts.value
.filter(c => c.iri !== null)
.map(c => ({
value: c.iri as string,
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
})),
)
const countryOptions = computed<RefOption[]>(() => {
const list = referentials.countries.value
return list.some(c => c.value === 'France')
? list
: [{ value: 'France', label: 'France' }, ...list]
})
const addressDegradedNotified = ref(false)
function onAddressDegraded(): void {
if (addressDegradedNotified.value) return
addressDegradedNotified.value = true
toast.warning({
title: t('technique.providers.toast.error'),
message: t('technique.providers.form.address.degraded'),
})
}
// Navigation + helpers
function goBack(): void {
router.push(`/providers/${providerId}`)
}
function apiErrorMessage(err: unknown): string {
const data = (err as { response?: { _data?: unknown } })?.response?._data
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
}
/** PATCH du bloc principal (groupe provider:write:main). */
async function onUpdateMain(): Promise<void> {
if (await updateMain()) {
toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
}
async function onSubmitContacts(): Promise<void> {
const ok = await submitContacts(err => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(err),
}))
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
async function onSubmitAddresses(): Promise<void> {
const ok = await submitAddresses(err => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(err),
}))
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
async function onSubmitAccounting(): Promise<void> {
const ok = await submitAccounting(
isBankRequired.value,
isRibRequired.value,
err => toast.error({ title: t('technique.providers.toast.error'), message: apiErrorMessage(err) }),
)
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
// Modal de confirmation generique
const confirmModal = reactive({
open: false,
message: '',
action: null as null | (() => void),
})
function askConfirm(message: string, action: () => void): void {
confirmModal.message = message
confirmModal.action = action
confirmModal.open = true
}
function runConfirm(): void {
confirmModal.action?.()
confirmModal.action = null
confirmModal.open = false
}
function askRemoveContact(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.contact'), () => removeContact(index))
}
function askRemoveAddress(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.address'), () => removeAddress(index))
}
function askRemoveRib(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index))
}
onMounted(async () => {
referentials.loadMain().catch(() => {})
if (canAccountingView.value) {
referentials.loadAccounting().catch(() => {})
}
await load()
prefill()
})
</script>
@@ -1,308 +0,0 @@
<template>
<div>
<!-- En-tete : retour repertoire + nom du prestataire + actions. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('technique.providers.consultation.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
<div class="ml-auto flex items-center gap-12">
<MalioButton
v-if="canEdit"
variant="secondary"
icon-name="mdi:pencil-outline"
icon-position="left"
:label="t('technique.providers.action.edit')"
@click="goEdit"
/>
<MalioButton
v-if="showArchive"
variant="secondary"
icon-name="mdi:archive-arrow-down-outline"
icon-position="left"
:label="t('technique.providers.action.archive')"
@click="askToggleArchive"
/>
<MalioButton
v-if="showRestore"
variant="secondary"
icon-name="mdi:archive-arrow-up-outline"
icon-position="left"
:label="t('technique.providers.action.restore')"
@click="askToggleArchive"
/>
</div>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('technique.providers.consultation.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('technique.providers.consultation.notFound') }}</p>
<template v-else-if="provider">
<!-- Bloc principal (lecture seule) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
:model-value="provider.companyName"
:label="t('technique.providers.form.main.companyName')"
readonly
/>
<MalioSelectCheckbox
:model-value="mainCategoryIris"
:options="mainCategoryOptions"
:label="t('technique.providers.form.main.categories')"
:display-tag="true"
readonly
/>
<MalioSelectCheckbox
:model-value="mainSiteIris"
:options="mainSiteOptions"
:label="t('technique.providers.form.main.sites')"
:display-tag="true"
readonly
/>
</div>
<!-- Onglets (navigation libre, tout en lecture seule) -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Contacts -->
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<ProviderContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
readonly
/>
</div>
</template>
<!-- Onglet Adresse -->
<template #address>
<div class="mt-12 flex flex-col gap-6">
<ProviderAddressBlock
v-for="(view, index) in addressViews"
:key="index"
:model-value="view.draft"
:category-options="view.categoryOptions"
:site-options="view.siteOptions"
:contact-options="contactOptions"
:country-options="countryOptionsFor(view.draft.country)"
readonly
/>
</div>
</template>
<!-- Onglets placeholder « A venir » (comme les autres modules). -->
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" readonly />
<MalioInputText :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" readonly />
<MalioSelect :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" readonly empty-option-label="" />
<MalioInputText :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" readonly />
<MalioSelect :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" readonly empty-option-label="" />
<MalioSelect :model-value="accounting.paymentTypeIri" :options="paymentTypeOptions" :label="t('technique.providers.form.accounting.paymentType')" readonly empty-option-label="" />
<MalioSelect v-if="isBankRequired" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" readonly empty-option-label="" />
</div>
</div>
<!-- Blocs RIB (uniquement si type de reglement = LCR). -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" readonly />
<MalioInputText :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" readonly />
<MalioInputText :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" readonly />
</div>
</div>
</div>
</template>
</MalioTabList>
</template>
<!-- Modal de confirmation archivage / restauration. -->
<MalioModal v-model="confirmArchive.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ confirmArchive.title }}</h2>
</template>
<p>{{ confirmArchive.message }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('technique.providers.form.confirmDelete.cancel')"
@click="confirmArchive.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="confirmArchive.confirmLabel"
@click="runToggleArchive"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useProvider } from '~/modules/technique/composables/useProvider'
import {
canEditProvider,
categoryOptionsOf,
contactOptionsOf,
irisOf,
mapAccountingDraft,
mapAddressToDraft,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf,
showArchiveAction,
showRestoreAction,
siteOptionsOf,
} from '~/modules/technique/utils/forms/providerDetail'
import { isBankRequiredForPaymentType, isRibRequiredForPaymentType } from '~/modules/technique/utils/forms/providerAccounting'
import { emptyProviderAddress, emptyProviderContact } from '~/modules/technique/types/providerForm'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { can, canAny } = usePermissions()
const providerId = route.params.id as string
const { provider, loading, error, load, archive, restore } = useProvider(providerId)
const canAccountingView = computed(() => can('technique.providers.accounting.view'))
const canEdit = computed(() => canEditProvider(canAny))
const isArchived = computed(() => provider.value?.isArchived ?? false)
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
const headerTitle = computed(() => provider.value?.companyName || t('technique.providers.consultation.title'))
useHead({ title: t('technique.providers.consultation.title') })
// Onglets (ordre spec : Contacts · Adresse · Rapports · Échanges · Comptabilité)
const activeTab = ref('contacts')
const TAB_ICONS: Record<string, string> = {
contacts: 'mdi:account-box-plus-outline',
address: 'mdi:map-marker-outline',
reports: 'mdi:file-chart-outline',
exchanges: 'mdi:swap-horizontal',
accounting: 'mdi:bank-circle-outline',
}
const tabs = computed(() => {
const keys = ['contacts', 'address', 'reports', 'exchanges']
if (canAccountingView.value) keys.push('accounting')
return keys.map(key => ({ key, label: t(`technique.providers.tab.${key}`), icon: TAB_ICONS[key] }))
})
// Donnees mappees depuis la SEULE reponse detail
const mainCategoryIris = computed(() => irisOf(provider.value?.categories))
const mainSiteIris = computed(() => irisOf(provider.value?.sites))
const mainCategoryOptions = computed(() => categoryOptionsOf(provider.value?.categories))
const mainSiteOptions = computed(() => siteOptionsOf(provider.value?.sites))
// Au moins un bloc affiche meme sans donnee (bloc vide en lecture seule, comme
// l'onglet Comptabilite et les autres modules pas de message « Aucun »).
const contacts = computed(() => {
const list = (provider.value?.contacts ?? []).map(mapContactToDraft)
return list.length > 0 ? list : [emptyProviderContact()]
})
// Contacts rattachables (pour resoudre les libelles des contacts lies aux adresses).
const contactOptions = computed(() => contactOptionsOf(provider.value?.contacts))
// Vue par adresse : brouillon + options propres a l'adresse (sites/categories embarques).
const addressViews = computed(() => {
const views = (provider.value?.addresses ?? []).map(address => ({
draft: mapAddressToDraft(address),
siteOptions: siteOptionsOf(address.sites),
categoryOptions: categoryOptionsOf(address.categories),
}))
return views.length > 0
? views
: [{ draft: emptyProviderAddress(), siteOptions: [], categoryOptions: [] }]
})
/** Pays : une seule option (la valeur courante), suffisant pour l'affichage readonly. */
function countryOptionsFor(country: string): { value: string, label: string }[] {
return country ? [{ value: country, label: country }] : []
}
// Comptabilite (presente uniquement si accounting.view)
const accounting = computed(() => mapAccountingDraft(provider.value ?? { id: 0, '@id': '' }))
const paymentTypeCode = computed(() => paymentTypeCodeOf(provider.value?.paymentType))
const isBankRequired = computed(() => isBankRequiredForPaymentType(paymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(paymentTypeCode.value))
const visibleRibs = computed(() => isRibRequired.value ? (provider.value?.ribs ?? []).map(mapRibToDraft) : [])
// Options « une entree » construites depuis l'embed (libelles role-independants).
const tvaModeOptions = computed(() => referentialOptionOf(provider.value?.tvaMode))
const paymentDelayOptions = computed(() => referentialOptionOf(provider.value?.paymentDelay))
const paymentTypeOptions = computed(() => referentialOptionOf(provider.value?.paymentType))
const bankOptions = computed(() => referentialOptionOf(provider.value?.bank))
// Navigation / actions
function goBack(): void {
router.push('/providers')
}
function goEdit(): void {
router.push(`/providers/${providerId}/edit`)
}
// Archivage / restauration
const confirmArchive = reactive({
open: false,
title: '',
message: '',
confirmLabel: '',
})
function askToggleArchive(): void {
const archiving = !isArchived.value
confirmArchive.title = archiving
? t('technique.providers.action.archive')
: t('technique.providers.action.restore')
confirmArchive.message = archiving
? t('technique.providers.consultation.confirmArchive')
: t('technique.providers.consultation.confirmRestore')
confirmArchive.confirmLabel = archiving
? t('technique.providers.action.archive')
: t('technique.providers.action.restore')
confirmArchive.open = true
}
async function runToggleArchive(): Promise<void> {
const archiving = !isArchived.value
confirmArchive.open = false
try {
await (archiving ? archive() : restore())
toast.success({
title: archiving
? t('technique.providers.toast.archiveSuccess')
: t('technique.providers.toast.restoreSuccess'),
})
}
catch {
// 409 a la restauration (homonyme actif) ou autre : toast generique.
toast.error({ title: t('technique.providers.toast.error') })
}
}
onMounted(load)
</script>
@@ -1,438 +0,0 @@
<template>
<div>
<PageHeader>
{{ t('technique.providers.title') }}
<template #actions>
<!-- gap-8 = 32px d'espacement entre Filtrer et Ajouter. -->
<div class="flex items-center gap-8">
<!-- Bouton Filtrer a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
<MalioButton
v-if="canView"
variant="tertiary"
:label="filterButtonLabel"
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
@click="openFilters"
/>
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('technique.providers.add')"
icon-name="mdi:add-bold"
icon-position="left"
@click="goToCreate"
/>
</div>
</template>
</PageHeader>
<!-- Datatable branchee sur usePaginatedList via useProvidersRepository :
pagination serveur, tri companyName ASC par defaut (cote back),
archives masques par defaut. Cloisonnement par site cote back. -->
<MalioDataTable
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
row-clickable
table-class="table-fixed providers-table"
:empty-message="t('technique.providers.empty')"
@row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
>
<!-- Categories : libelles (name) separes par une virgule. -->
<template #cell-categories="{ item }">
{{ formatCategories(item) }}
</template>
<!-- Sites : badges colores (name + color), relation directe du prestataire. -->
<template #cell-sites="{ item }">
<span class="flex flex-wrap gap-1">
<span
v-for="site in (item.sites as ProviderSite[])"
:key="site.id"
class="inline-flex items-center rounded-full px-2 py-0.5 font-medium text-white"
:style="{ backgroundColor: site.color }"
>
{{ site.name }}
</span>
</span>
</template>
<!-- Derniere activite : date de derniere modification (updatedAt), format JJ-MM-AAAA. -->
<template #cell-lastActivity="{ item }">
{{ formatLastActivity(item) }}
</template>
</MalioDataTable>
<div class="flex justify-center mt-4">
<MalioButton
v-if="canView"
variant="primary"
:label="t('technique.providers.export')"
:disabled="exporting"
@click="exportXlsx"
/>
</div>
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
« Voir les résultats ». Meme pattern que le repertoire fournisseurs.
Etat 100 % local, jamais dans l'URL (regle ABSOLUE n°6). -->
<MalioDrawer
v-model="filterDrawerOpen"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between border-t border-black p-6"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('technique.providers.filters.title') }}</h2>
</template>
<MalioAccordion>
<!-- Recherche : nom entreprise + contact + email (param `search`). -->
<MalioAccordionItem :title="t('technique.providers.filters.search')" value="search">
<MalioInputText
v-model="draftSearch"
icon-name="mdi:magnify"
/>
</MalioAccordionItem>
<!-- Categories (type PRESTATAIRE) : cases a cocher (multi). Valeur = code stable. -->
<MalioAccordionItem :title="t('technique.providers.filters.categories')" value="categories">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in categoryOptions"
:id="`filter-category-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftCategoryCodes.includes(opt.value)"
@update:model-value="(val: boolean) => toggleCategory(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<!-- Sites : cases a cocher (multi). Valeur = id du site. -->
<MalioAccordionItem :title="t('technique.providers.filters.sites')" value="sites">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in siteOptions"
:id="`filter-site-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftSiteIds.includes(opt.value)"
@update:model-value="(val: boolean) => toggleSite(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
<MalioAccordionItem :title="t('technique.providers.filters.status')" value="status">
<MalioCheckbox
id="filter-include-archived"
:label="t('technique.providers.filters.includeArchived')"
:model-value="draftIncludeArchived"
@update:model-value="(val: boolean) => draftIncludeArchived = val"
/>
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton
variant="tertiary"
:label="t('technique.providers.filters.reset')"
button-class="w-m-btn-action"
@click="resetFilters"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.filters.apply')"
button-class="w-[170px]"
@click="applyFilters"
/>
</template>
</MalioDrawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { Provider, ProviderSite } from '~/modules/technique/composables/useProvidersRepository'
interface FilterOption {
value: string
label: string
}
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
useHead({ title: t('technique.providers.title') })
// Bouton « Ajouter » reserve a `manage` (POST /providers garde manage seul
// Compta cree pas). « Exporter » et « Filtrer » suivent `view`.
const canManage = computed(() => can('technique.providers.manage'))
const canView = computed(() => can('technique.providers.view'))
const {
items: providers,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadProviders,
goToPage,
setItemsPerPage,
setFilters,
} = useProvidersRepository()
// Mappe les prestataires en objets « plats » pour MalioDataTable (items typees
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
// implicite, contrairement a l'interface Provider. Meme pattern que fournisseurs.
const rows = computed(() => providers.value.map(provider => ({
id: provider.id,
companyName: provider.companyName,
categories: provider.categories,
sites: provider.sites,
updatedAt: provider.updatedAt,
})))
const columns = [
{ key: 'companyName', label: t('technique.providers.column.companyName') },
{ key: 'categories', label: t('technique.providers.column.categories') },
{ key: 'sites', label: t('technique.providers.column.sites') },
{ key: 'lastActivity', label: t('technique.providers.column.lastActivity') },
]
/** Libelles des categories du prestataire, separes par une virgule (name). */
function formatCategories(item: Record<string, unknown>): string {
const categories = (item.categories as Provider['categories']) ?? []
return categories.map(c => c.name).join(', ')
}
/**
* Derniere activite : date de derniere modification de la fiche (updatedAt,
* expose en liste via default:read). Format court francais JJ-MM-AAAA (tirets,
* cf. spec-front M3 § Datatable).
*/
function formatLastActivity(item: Record<string, unknown>): string {
const value = item.updatedAt as string | null | undefined
if (!value) {
return ''
}
// Garde-fou date invalide : un updatedAt mal forme donnerait « Invalid Date ».
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = date.getFullYear()
return `${day}-${month}-${year}`
}
/** Clic sur une ligne → ecran Consultation (route a plat /providers/{id}). */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/providers/${item.id}`)
}
function goToCreate(): void {
router.push('/providers/new')
}
// Filtres (drawer)
// Deux niveaux d'etat (pattern repertoire fournisseurs) :
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
// uniquement au clic « Voir les résultats » / « Réinitialiser ».
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
const filterDrawerOpen = ref(false)
const draftSearch = ref('')
const draftCategoryCodes = ref<string[]>([])
const draftSiteIds = ref<string[]>([])
const draftIncludeArchived = ref(false)
const appliedSearch = ref('')
const appliedCategoryCodes = ref<string[]>([])
const appliedSiteIds = ref<string[]>([])
const appliedIncludeArchived = ref(false)
// Options des selects multi, chargees une fois (referentiels courts).
const categoryOptions = ref<FilterOption[]>([])
const siteOptions = ref<FilterOption[]>([])
const activeFilterCount = computed(() => {
let count = 0
if (appliedSearch.value.trim() !== '') count++
if (appliedCategoryCodes.value.length > 0) count++
if (appliedSiteIds.value.length > 0) count++
if (appliedIncludeArchived.value) count++
return count
})
const filterButtonLabel = computed(() => {
const base = t('technique.providers.filters.title')
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
})
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la
// reouverture reflete les filtres actifs.
function openFilters(): void {
draftSearch.value = appliedSearch.value
draftCategoryCodes.value = [...appliedCategoryCodes.value]
draftSiteIds.value = [...appliedSiteIds.value]
draftIncludeArchived.value = appliedIncludeArchived.value
filterDrawerOpen.value = true
}
function toggleCategory(code: string, selected: boolean): void {
draftCategoryCodes.value = selected
? [...draftCategoryCodes.value, code]
: draftCategoryCodes.value.filter(c => c !== code)
}
function toggleSite(id: string, selected: boolean): void {
draftSiteIds.value = selected
? [...draftSiteIds.value, id]
: draftSiteIds.value.filter(s => s !== id)
}
/**
* Construit le payload de filtres serveur a partir de l'etat applique. Cles
* `categoryCode[]` / `siteId[]` pour que PHP les parse en tableaux (OR cote back).
* Les filtres vides sont omis pour une query propre.
*/
function buildFilterPayload(): Record<string, string | string[] | boolean> {
const payload: Record<string, string | string[] | boolean> = {}
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
if (appliedIncludeArchived.value) payload.includeArchived = true
return payload
}
// « Voir les résultats » : recopie brouillon applied, pousse les filtres
// (retombe en page 1 via usePaginatedList) et ferme le drawer.
function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim()
appliedCategoryCodes.value = [...draftCategoryCodes.value]
appliedSiteIds.value = [...draftSiteIds.value]
appliedIncludeArchived.value = draftIncludeArchived.value
setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false
}
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
// Le drawer reste ouvert pour montrer le formulaire vide.
function resetFilters(): void {
draftSearch.value = ''
draftCategoryCodes.value = []
draftSiteIds.value = []
draftIncludeArchived.value = false
appliedSearch.value = ''
appliedCategoryCodes.value = []
appliedSiteIds.value = []
appliedIncludeArchived.value = false
setFilters({}, { replace: true })
}
/** Charge les referentiels du drawer (categories PRESTATAIRE + sites) via ?pagination=false. */
async function loadFilterOptions(): Promise<void> {
const [cats, sites] = await Promise.all([
api.get<{ member?: Array<{ code: string, name: string }> }>(
'/categories',
// Taxonomie multi-types : le filtre du repertoire prestataires ne
// propose que les categories de type PRESTATAIRE.
{ pagination: 'false', typeCode: 'PRESTATAIRE' },
{ headers: { Accept: 'application/ld+json' }, toast: false },
),
api.get<{ member?: Array<{ id: number, name: string }> }>(
'/sites',
{ pagination: 'false' },
{ headers: { Accept: 'application/ld+json' }, toast: false },
),
])
categoryOptions.value = (cats.member ?? []).map(c => ({ value: c.code, label: c.name }))
siteOptions.value = (sites.member ?? []).map(s => ({ value: String(s.id), label: s.name }))
}
// Export XLSX
// Memes filtres que la vue. La colonne SIREN n'est dans le fichier que si
// l'utilisateur a accounting.view (gere cote back).
const exporting = ref(false)
async function exportXlsx(): Promise<void> {
if (exporting.value) {
return
}
exporting.value = true
try {
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
// contenu faute d'overload blob sur le client partage meme approche que
// l'export fournisseurs.
const blob = await api.get<Blob>('/providers/export.xlsx', buildFilterPayload(), {
responseType: 'blob',
toast: false,
} as unknown as Parameters<typeof api.get>[2])
triggerDownload(blob, 'repertoire-prestataires.xlsx')
}
catch {
toast.error({
title: t('technique.providers.toast.error'),
message: t('technique.providers.toast.exportError'),
})
}
finally {
exporting.value = false
}
}
/** Declenche le telechargement d'un blob via un lien temporaire. */
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
onMounted(() => {
loadProviders()
// Echec du chargement des referentiels non bloquant : la liste s'affiche,
// l'utilisateur perd juste les options de filtre.
loadFilterOptions().catch(() => {
categoryOptions.value = []
siteOptions.value = []
})
})
</script>
<style scoped>
/*
* Colonne Sites uniquement (3e colonne : companyName, categories, SITES,
* lastActivity) : ses badges rendent la cellule trop haute. On reduit le padding
* vertical de SON td (16px Malio -> 8px) sans toucher les autres colonnes ni les
* couleurs/tailles (qui restent sur les defauts Malio).
*/
:deep(.providers-table tbody td:nth-child(3)) {
padding-top: 8px;
padding-bottom: 8px;
}
</style>
@@ -1,535 +0,0 @@
<template>
<div>
<!-- En-tete : retour vers le repertoire + titre. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('technique.providers.form.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('technique.providers.form.title') }}</h1>
</div>
<!-- Formulaire principal (pre-onglets)
Sans validation de ce bloc, les onglets restent inaccessibles. Au
succes du POST, les champs passent en lecture seule et on bascule
automatiquement sur l'onglet Contact (PAS d'onglet Information au M3).
Selecteur de site present ici (RG-3.03, relation directe). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:label="t('technique.providers.form.main.companyName')"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:label="t('technique.providers.form.main.categories')"
:display-tag="true"
:readonly="mainLocked"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
<MalioSelectCheckbox
:model-value="main.siteIris"
:options="referentials.sites.value"
:label="t('technique.providers.form.main.sites')"
:display-tag="true"
:readonly="mainLocked"
:required="true"
:error="mainErrors.errors.sites"
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
/>
</div>
<div v-if="!mainLocked" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="mainSubmitting"
@click="submitMain"
/>
</div>
<!-- Onglets a validation incrementale
Onglet Contact actif (ERP-142) ; Adresse / Comptabilite arrivent aux
tickets ERP-143 / 144 : placeholders « A venir » pour l'instant. -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
<template #contact>
<div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ProviderContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:removable="isRowRemovable(contacts, index)"
:readonly="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div v-if="!isValidated('contact')" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('technique.providers.form.contact.add')"
:disabled="!canAddContact"
@click="addContact"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting || providerId === null"
@click="onSubmitContacts"
/>
</div>
</div>
</template>
<!-- Onglet Adresse : saisie multi-adresses (blocs ajoutables). -->
<template #address>
<div class="mt-12 flex flex-col gap-6">
<ProviderAddressBlock
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:category-options="referentials.categories.value"
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="isRowRemovable(addresses, index)"
:readonly="isValidated('address')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<div v-if="!isValidated('address')" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('technique.providers.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting || providerId === null"
@click="onSubmitAddresses"
/>
</div>
</div>
</template>
<!-- Onglet Comptabilite (present uniquement si accounting.view ; editable si manage). -->
<template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:label="t('technique.providers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value"
:label="t('technique.providers.form.accounting.tvaMode')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:label="t('technique.providers.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value"
:label="t('technique.providers.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value"
:label="t('technique.providers.form.accounting.paymentType')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange"
/>
<!-- Banque : visible et obligatoire seulement si VIREMENT (RG-3.07). -->
<MalioSelect
v-if="isBankRequired"
:model-value="accounting.bankIri"
:options="referentials.banks.value"
:label="t('technique.providers.form.accounting.bank')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/>
</div>
</div>
<!-- Blocs RIB affiches uniquement si type de reglement = LCR (RG-3.08). -->
<div
v-for="(rib, index) in visibleRibs"
:key="index"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:label="t('technique.providers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:label="t('technique.providers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="true"
:error="ribErrors[index]?.iban"
/>
</div>
</div>
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
<MalioButton
v-if="isRibRequired"
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('technique.providers.form.accounting.addRib')"
:disabled="!canAddRib"
@click="addRib"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting || providerId === null"
@click="onSubmitAccounting"
/>
</div>
</div>
</template>
</MalioTabList>
<!-- Modal de confirmation generique (suppression d'un bloc contact). -->
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2>
</template>
<p>{{ confirmModal.message }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('technique.providers.form.confirmDelete.cancel')"
@click="confirmModal.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="t('technique.providers.form.confirmDelete.confirm')"
@click="runConfirm"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useProviderReferentials, type RefOption } from '~/modules/technique/composables/useProviderReferentials'
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
import {
isBankRequiredForPaymentType,
isRibRequiredForPaymentType,
} from '~/modules/technique/utils/forms/providerAccounting'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
const { t } = useI18n()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
useHead({ title: t('technique.providers.form.title') })
// Gating de la route : la creation est reservee a `manage` (POST /providers garde
// manage seul Compta ne cree pas). Compta (accounting seul) et Usine sont
// rediriges vers le repertoire.
if (!can('technique.providers.manage')) {
await navigateTo('/providers')
}
const referentials = useProviderReferentials()
const {
main,
providerId,
mainLocked,
mainSubmitting,
mainErrors,
canAccountingView,
tabKeys,
activeTab,
unlockedIndex,
submitMain,
tabSubmitting,
isValidated,
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
addresses,
addressErrors,
canAddAddress,
addAddress,
removeAddress,
submitAddresses,
accounting,
ribs,
accountingErrors,
ribErrors,
accountingReadonly,
setPaymentType,
canAddRib,
addRib,
removeRib,
submitAccounting,
} = useProviderForm()
/** Retour vers le repertoire prestataires (fleche d'en-tete). */
function goBack(): void {
router.push('/providers')
}
/**
* Message d'erreur a afficher dans un toast a partir d'une erreur d'API. Retourne
* TOUJOURS une chaine (le composant de toast plante sur `undefined`).
*/
function apiErrorMessage(error: unknown): string {
const data = (error as { response?: { _data?: unknown } })?.response?._data
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
}
// Dernier onglet REMPLISSABLE par le role : tabKeys exclut deja la Comptabilite
// si l'user n'a pas accounting.view. Sa validation cloture l'ajout (redirection).
const lastFillableTab = computed(() => tabKeys.value[tabKeys.value.length - 1])
/**
* Apres validation d'un onglet (creation) : si c'est le dernier onglet du role,
* l'ajout est termine -> toast final + retour au repertoire (miroir M1/M2) ; sinon
* toast de mise a jour (l'onglet suivant a deja ete deverrouille par completeTab).
*/
function onTabSaved(key: string): void {
if (key === lastFillableTab.value) {
toast.success({ title: t('technique.providers.toast.addComplete') })
router.push('/providers')
return
}
toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
// Onglet Contact
/** Valide l'onglet Contact ; redirige si c'est le dernier onglet du role. */
async function onSubmitContacts(): Promise<void> {
const ok = await submitContacts(error => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
onTabSaved('contact')
}
}
function askRemoveContact(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.contact'), () => removeContact(index))
}
// Onglet Adresse
// Contacts deja persistes (IRI non nul), rattachables a une adresse (M2M). Le
// libelle reprend le nom complet, a defaut l'email.
const contactOptions = computed<RefOption[]>(() =>
contacts.value
.filter(c => c.iri !== null)
.map(c => ({
value: c.iri as string,
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
})),
)
// Pays : France garantie en tete meme si /countries echoue (resilience ERP-102),
// pour rester preselectionnable par defaut sur chaque adresse.
const countryOptions = computed<RefOption[]>(() => {
const list = referentials.countries.value
return list.some(c => c.value === 'France')
? list
: [{ value: 'France', label: 'France' }, ...list]
})
const addressDegradedNotified = ref(false)
/** Avertit une seule fois quand l'autocompletion d'adresse bascule en degrade (RG-3.06). */
function onAddressDegraded(): void {
if (addressDegradedNotified.value) {
return
}
addressDegradedNotified.value = true
toast.warning({
title: t('technique.providers.toast.error'),
message: t('technique.providers.form.address.degraded'),
})
}
/** Valide l'onglet Adresse ; redirige si c'est le dernier onglet du role. */
async function onSubmitAddresses(): Promise<void> {
const ok = await submitAddresses(error => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
onTabSaved('address')
}
}
function askRemoveAddress(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.address'), () => removeAddress(index))
}
// Onglet Comptabilite
// Code stable du type de reglement selectionne (pour RG-3.07 / RG-3.08).
const selectedPaymentTypeCode = computed(() =>
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
)
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
// Les blocs RIB ne sont affiches que pour une LCR (RG-3.08).
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
/** Changement de type de reglement : propage les RG inter-champs (banque / RIB). */
function onPaymentTypeChange(value: string | number | null): void {
const iri = value === null ? null : String(value)
const code = referentials.paymentTypes.value.find(p => p.value === iri)?.code ?? null
setPaymentType(iri, isBankRequiredForPaymentType(code), isRibRequiredForPaymentType(code))
}
function askRemoveRib(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index))
}
/** Valide l'onglet Comptabilite ; redirige si c'est le dernier onglet du role. */
async function onSubmitAccounting(): Promise<void> {
const ok = await submitAccounting(
isBankRequired.value,
isRibRequired.value,
error => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(error),
}),
)
if (ok) {
onTabSaved('accounting')
}
}
// Modal de confirmation generique
const confirmModal = reactive({
open: false,
message: '',
action: null as null | (() => void),
})
function askConfirm(message: string, action: () => void): void {
confirmModal.message = message
confirmModal.action = action
confirmModal.open = true
}
function runConfirm(): void {
confirmModal.action?.()
confirmModal.action = null
confirmModal.open = false
}
// Icone (Iconify) affichee dans l'onglet, par cle.
const TAB_ICONS: Record<string, string> = {
contact: 'mdi:account-box-plus-outline',
address: 'mdi:map-marker-outline',
accounting: 'mdi:bank-circle-outline',
}
// Onglets desactives tant que le formulaire principal n'est pas valide
// (unlockedIndex = -1 au depart) ; deverrouillage progressif ensuite.
const tabs = computed(() => tabKeys.value.map((key, index) => ({
key,
label: t(`technique.providers.tab.${key}`),
icon: TAB_ICONS[key],
disabled: index > unlockedIndex.value,
})))
onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides.
referentials.loadMain().catch(() => {})
// Referentiels comptables charges uniquement si l'onglet est accessible.
if (canAccountingView.value) {
referentials.loadAccounting().catch(() => {})
}
})
</script>
@@ -1,177 +0,0 @@
/**
* Types « brouillon » de l'ecran « Ajouter un prestataire » (M3 Technique).
*
* Miroir reduit de `types/supplierForm.ts` (M2) : le M3 n'a PAS d'onglet
* Information, et porte en plus un selecteur de site SUR le formulaire principal
* (RG-3.03 relation directe `provider.sites`, distincte des sites d'adresse).
*
* Ces interfaces decrivent l'etat LOCAL du formulaire (refs Vue), distinct des
* DTO de l'API : la page de creation (ERP-141) et — a venir — les blocs d'onglet
* Contact / Adresse / Comptabilite (ERP-142 144) les partagent.
*
* Les relations M2M (categories, sites) sont portees par leurs IRI Hydra (`@id`),
* envoyees telles quelles dans le payload POST (cf. contrat back ERP-139 :
* `categories: ['/api/categories/{id}']`, `sites: ['/api/sites/{id}']`).
*/
/** Etat « plat » du formulaire principal (groupe `provider:write:main`). */
export interface ProviderMainDraft {
/** Nom de l'entreprise prestataire. UPPERCASE serveur (RG-3.11), unicite RG-3.10. */
companyName: string | null
/** IRI des categories rattachees (M2M, type PRESTATAIRE — RG-3.09 ; >= 1). */
categoryIris: string[]
/** IRI des sites rattaches DIRECTEMENT au prestataire (M2M `provider_site`, RG-3.03 ; >= 1). */
siteIris: string[]
}
/** Fabrique un formulaire principal vierge. */
export function emptyProviderMain(): ProviderMainDraft {
return {
companyName: null,
categoryIris: [],
siteIris: [],
}
}
/** Reponse minimale du POST /providers exploitee par l'ecran de creation. */
export interface ProviderMainResponse {
id: number
/** Nom renvoye normalise (UPPERCASE) par le serveur, reaffiche en lecture seule. */
companyName: string | null
}
/**
* Un contact du prestataire (onglet Contact, ERP-142). Miroir de
* `SupplierContactFormDraft` (M2). Tous les champs sont nullable cote ORM ; la
* validite (RG-3.04) tient a la presence d'AU MOINS un champ rempli parmi
* prenom / nom / fonction / telephone principal / email (cf. back).
*/
export interface ProviderContactFormDraft {
/** Id serveur une fois le contact cree (null tant que non persiste). */
id: number | null
/** IRI Hydra du contact cree — servira au rattachement M2M cote adresse (ERP-143). */
iri: string | null
firstName: string | null
lastName: string | null
jobTitle: string | null
phonePrimary: string | null
phoneSecondary: string | null
email: string | null
/** UI : le 2e numero a ete revele via le bouton « + » (max 2 telephones). */
hasSecondaryPhone: boolean
}
/** Fabrique un contact vierge. */
export function emptyProviderContact(): ProviderContactFormDraft {
return {
id: null,
iri: null,
firstName: null,
lastName: null,
jobTitle: null,
phonePrimary: null,
phoneSecondary: null,
email: null,
hasSecondaryPhone: false,
}
}
/** Reponse du POST /providers/{id}/contacts (groupe provider:item:read + IRI Hydra). */
export interface ProviderContactResponse {
'@id'?: string
id: number
}
/**
* Une adresse du prestataire (onglet Adresse, ERP-143). Version SIMPLIFIEE de
* `SupplierAddressFormDraft` (M2) : PAS de type d'adresse (Prospect/Depart/Rendu),
* PAS de bennes, PAS de prestation de triage. Champs postaux + M2M sites /
* categories / contacts (par IRI).
*/
export interface ProviderAddressFormDraft {
/** Id serveur une fois l'adresse creee (null tant que non persistee). */
id: number | null
/** Pays (chaine libre, defaut « France »). */
country: string
postalCode: string | null
city: string | null
street: string | null
streetComplement: string | null
/** IRI des categories rattachees (type PRESTATAIRE, RG-3.09 ; >= 1). */
categoryIris: string[]
/** IRI des sites rattaches a l'adresse (M2M `provider_address_site`, RG-3.05 ; >= 1). */
siteIris: string[]
/** IRI des contacts rattaches (= blocs Contact deja persistes de l'onglet Contact). */
contactIris: string[]
}
/** Fabrique une adresse vierge (France presaisi). */
export function emptyProviderAddress(): ProviderAddressFormDraft {
return {
id: null,
country: 'France',
postalCode: null,
city: null,
street: null,
streetComplement: null,
categoryIris: [],
siteIris: [],
contactIris: [],
}
}
/** Reponse du POST /providers/{id}/addresses (id suffisant pour le suivi cote front). */
export interface ProviderAddressResponse {
id: number
}
/**
* Etat « plat » de l'onglet Comptabilite (groupe `provider:write:accounting`).
* Relations (TVA / delai / type de reglement / banque) portees par leur IRI.
*/
export interface ProviderAccountingDraft {
siren: string | null
accountNumber: string | null
tvaModeIri: string | null
nTva: string | null
paymentDelayIri: string | null
paymentTypeIri: string | null
/** Banque : requise et envoyee uniquement si Type de reglement = VIREMENT (RG-3.07). */
bankIri: string | null
}
/** Fabrique un onglet Comptabilite vierge. */
export function emptyProviderAccounting(): ProviderAccountingDraft {
return {
siren: null,
accountNumber: null,
tvaModeIri: null,
nTva: null,
paymentDelayIri: null,
paymentTypeIri: null,
bankIri: null,
}
}
/** Un RIB du prestataire (sous-collection comptable, obligatoire si Type = LCR — RG-3.08). */
export interface ProviderRibFormDraft {
id: number | null
label: string | null
bic: string | null
iban: string | null
}
/** Fabrique un RIB vierge. */
export function emptyProviderRib(): ProviderRibFormDraft {
return {
id: null,
label: null,
bic: null,
iban: null,
}
}
/** Reponse du POST /providers/{id}/ribs (id suffisant pour le suivi cote front). */
export interface ProviderRibResponse {
id: number
}
@@ -1,83 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
buildProviderAccountingPayload,
buildProviderRibPayload,
isBankRequiredForPaymentType,
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
} from '../providerAccounting'
import { emptyProviderAccounting, emptyProviderRib } from '~/modules/technique/types/providerForm'
/**
* Helpers purs de l'onglet Comptabilite prestataire (ERP-144) : RG inter-champs
* RG-3.07 (banque si VIREMENT) / RG-3.08 (RIB si LCR) + construction des payloads.
*/
describe('providerAccounting helpers', () => {
describe('RG-3.07 / RG-3.08 — type de reglement', () => {
it('banque requise uniquement pour VIREMENT', () => {
expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true)
expect(isBankRequiredForPaymentType('LCR')).toBe(false)
expect(isBankRequiredForPaymentType('CHEQUE')).toBe(false)
expect(isBankRequiredForPaymentType(null)).toBe(false)
})
it('RIB requis uniquement pour LCR', () => {
expect(isRibRequiredForPaymentType('LCR')).toBe(true)
expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false)
expect(isRibRequiredForPaymentType(null)).toBe(false)
})
})
describe('isRibBlank / isRibComplete', () => {
it('un RIB vierge est vide et incomplet', () => {
expect(isRibBlank(emptyProviderRib())).toBe(true)
expect(isRibComplete(emptyProviderRib())).toBe(false)
})
it('un RIB partiel n\'est ni vide ni complet', () => {
const rib = { ...emptyProviderRib(), iban: 'FR76...' }
expect(isRibBlank(rib)).toBe(false)
expect(isRibComplete(rib)).toBe(false)
})
it('un RIB avec libelle + BIC + IBAN est complet', () => {
const rib = { ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }
expect(isRibComplete(rib)).toBe(true)
})
})
describe('buildProviderAccountingPayload (RG-3.07)', () => {
it('envoie la banque si requise (VIREMENT)', () => {
const payload = buildProviderAccountingPayload({
...emptyProviderAccounting(),
paymentTypeIri: '/api/payment_types/3',
bankIri: '/api/banks/2',
}, true)
expect(payload.bank).toBe('/api/banks/2')
expect(payload.paymentType).toBe('/api/payment_types/3')
})
it('force la banque a null si non requise (hors VIREMENT)', () => {
const payload = buildProviderAccountingPayload({
...emptyProviderAccounting(),
bankIri: '/api/banks/2',
}, false)
expect(payload.bank).toBeNull()
})
})
describe('buildProviderRibPayload', () => {
it('omet les champs requis vides (NotBlank back joue sur le champ)', () => {
const payload = buildProviderRibPayload(emptyProviderRib())
expect(payload).not.toHaveProperty('label')
expect(payload).not.toHaveProperty('bic')
expect(payload).not.toHaveProperty('iban')
})
it('conserve les champs remplis', () => {
const payload = buildProviderRibPayload({ ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' })
expect(payload).toEqual({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' })
})
})
})
@@ -1,73 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
buildProviderAddressPayload,
isProviderAddressValid,
} from '../providerAddress'
import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
/**
* Helpers purs de l'onglet Adresse prestataire (ERP-143). RG-3.05 (>= 1 site) et
* construction du payload de sous-ressource (relations en IRI, requis vides omis,
* pas de type d'adresse / bennes / triage difference M2).
*/
describe('providerAddress helpers', () => {
const SITE = '/api/sites/1'
const CAT = '/api/categories/7'
describe('isProviderAddressValid (RG-3.05 / RG-3.09)', () => {
it('false sans site', () => {
const address = { ...emptyProviderAddress(), categoryIris: [CAT] }
expect(isProviderAddressValid(address)).toBe(false)
})
it('false sans categorie', () => {
const address = { ...emptyProviderAddress(), siteIris: [SITE] }
expect(isProviderAddressValid(address)).toBe(false)
})
it('true avec au moins un site ET une categorie', () => {
const address = { ...emptyProviderAddress(), siteIris: [SITE], categoryIris: [CAT] }
expect(isProviderAddressValid(address)).toBe(true)
})
})
describe('buildProviderAddressPayload', () => {
it('mappe les relations en IRI et n\'embarque PAS type/bennes/triage (difference M2)', () => {
const payload = buildProviderAddressPayload({
...emptyProviderAddress(),
postalCode: '86100',
city: 'Châtellerault',
street: '1 rue du Test',
siteIris: [SITE],
categoryIris: [CAT],
contactIris: ['/api/provider_contacts/9'],
})
expect(payload).toEqual({
country: 'France',
postalCode: '86100',
city: 'Châtellerault',
street: '1 rue du Test',
streetComplement: null,
categories: [CAT],
sites: [SITE],
contacts: ['/api/provider_contacts/9'],
})
expect(payload).not.toHaveProperty('addressType')
expect(payload).not.toHaveProperty('bennes')
expect(payload).not.toHaveProperty('triageProvider')
})
it('omet les scalaires requis vides (NotBlank back joue sur le champ)', () => {
const payload = buildProviderAddressPayload({
...emptyProviderAddress(),
siteIris: [SITE],
categoryIris: [CAT],
})
expect(payload).not.toHaveProperty('postalCode')
expect(payload).not.toHaveProperty('city')
expect(payload).not.toHaveProperty('street')
// streetComplement n'est PAS requis -> reste present a null.
expect(payload).toHaveProperty('streetComplement', null)
})
})
})
@@ -1,93 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
buildProviderContactPayload,
hasAtLeastOneFilledContact,
isProviderContactBlank,
isProviderContactNamed,
} from '../providerContact'
import { emptyProviderContact } from '~/modules/technique/types/providerForm'
/**
* Helpers purs de l'onglet Contact prestataire (ERP-142). On verifie la
* definition de « bloc vide » (RG-3.04, alignee sur le back) et la construction
* du payload de sous-ressource.
*/
describe('providerContact helpers', () => {
describe('isProviderContactBlank (RG-3.04)', () => {
it('un bloc vierge est vide', () => {
expect(isProviderContactBlank(emptyProviderContact())).toBe(true)
})
it('un seul champ rempli parmi nom/prenom/fonction/tel/email suffit a le rendre non vide', () => {
for (const field of ['firstName', 'lastName', 'jobTitle', 'phonePrimary', 'email'] as const) {
const contact = { ...emptyProviderContact(), [field]: 'x' }
expect(isProviderContactBlank(contact)).toBe(false)
}
})
it('ignore les espaces (trim) — un champ blanc ne compte pas', () => {
expect(isProviderContactBlank({ ...emptyProviderContact(), lastName: ' ' })).toBe(true)
})
it('un 2e telephone seul NE suffit PAS (exclu, comme le back)', () => {
const contact = { ...emptyProviderContact(), hasSecondaryPhone: true, phoneSecondary: '0102030405' }
expect(isProviderContactBlank(contact)).toBe(true)
})
})
describe('isProviderContactNamed (RG-3.04 — prenom OU nom)', () => {
it('vrai avec un prenom seul ou un nom seul', () => {
expect(isProviderContactNamed({ ...emptyProviderContact(), firstName: 'Jean' })).toBe(true)
expect(isProviderContactNamed({ ...emptyProviderContact(), lastName: 'Dupont' })).toBe(true)
})
it('faux si seuls fonction / telephone / email sont remplis (ne suffit pas)', () => {
expect(isProviderContactNamed({ ...emptyProviderContact(), jobTitle: 'Directeur' })).toBe(false)
expect(isProviderContactNamed({ ...emptyProviderContact(), email: 'a@b.fr' })).toBe(false)
expect(isProviderContactNamed({ ...emptyProviderContact(), phonePrimary: '0102030405' })).toBe(false)
})
})
describe('hasAtLeastOneFilledContact (RG-3.12 — au moins un contact nomme)', () => {
it('false si aucun bloc n\'est nomme', () => {
expect(hasAtLeastOneFilledContact([emptyProviderContact(), { ...emptyProviderContact(), email: 'a@b.fr' }])).toBe(false)
})
it('true des qu\'un bloc porte un nom ou prenom', () => {
expect(hasAtLeastOneFilledContact([
emptyProviderContact(),
{ ...emptyProviderContact(), lastName: 'Dupont' },
])).toBe(true)
})
})
describe('buildProviderContactPayload', () => {
it('mappe les champs et envoie null pour les vides', () => {
const payload = buildProviderContactPayload({ ...emptyProviderContact(), lastName: 'Doe' })
expect(payload).toEqual({
firstName: null,
lastName: 'Doe',
jobTitle: null,
phonePrimary: null,
phoneSecondary: null,
email: null,
})
})
it('n\'envoie le 2e telephone que si revele (max 2)', () => {
const masque = buildProviderContactPayload({
...emptyProviderContact(),
phoneSecondary: '0102030405',
hasSecondaryPhone: false,
})
expect(masque.phoneSecondary).toBeNull()
const revele = buildProviderContactPayload({
...emptyProviderContact(),
phoneSecondary: '0102030405',
hasSecondaryPhone: true,
})
expect(revele.phoneSecondary).toBe('0102030405')
})
})
})
@@ -1,167 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
// formatPhoneFR est auto-importe dans le helper via le chemin partage ; on le mocke
// pour un rendu deterministe (la mise en forme exacte est testee ailleurs).
vi.mock('~/shared/utils/phone', () => ({
formatPhoneFR: (v: string) => `fmt(${v})`,
}))
const {
canEditProvider,
categoryOptionsOf,
contactOptionsOf,
iriOf,
irisOf,
mapAccountingDraft,
mapAddressToDraft,
mapContactToDraft,
mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf,
showArchiveAction,
showRestoreAction,
siteOptionsOf,
} = await import('../providerDetail')
/**
* Helpers purs des ecrans Consultation / Modification (ERP-145) : mapping du
* detail embarque vers les brouillons + regles d'affichage des actions (Modifier /
* Archiver / Restaurer).
*/
describe('providerDetail helpers', () => {
describe('iriOf / irisOf', () => {
it('extrait l\'IRI d\'un objet embarque, d\'un IRI nu, ou null', () => {
expect(iriOf({ '@id': '/api/banks/2' })).toBe('/api/banks/2')
expect(iriOf('/api/banks/2')).toBe('/api/banks/2')
expect(iriOf(null)).toBeNull()
expect(iriOf(undefined)).toBeNull()
})
it('extrait les IRI d\'une collection embarquee', () => {
expect(irisOf([{ '@id': '/api/sites/1' }, { '@id': '/api/sites/2' }])).toEqual(['/api/sites/1', '/api/sites/2'])
expect(irisOf(undefined)).toEqual([])
})
})
describe('mapContactToDraft', () => {
it('mappe les champs, formate les telephones et derive hasSecondaryPhone', () => {
const draft = mapContactToDraft({
'@id': '/api/provider_contacts/5',
id: 5,
firstName: 'Jean',
lastName: 'Dupont',
phonePrimary: '0102030405',
phoneSecondary: '0607080910',
email: 'jean@x.fr',
})
expect(draft).toMatchObject({
id: 5,
iri: '/api/provider_contacts/5',
firstName: 'Jean',
lastName: 'Dupont',
phonePrimary: 'fmt(0102030405)',
phoneSecondary: 'fmt(0607080910)',
email: 'jean@x.fr',
hasSecondaryPhone: true,
})
})
it('hasSecondaryPhone faux sans 2e numero', () => {
const draft = mapContactToDraft({ '@id': '/api/provider_contacts/6', id: 6, lastName: 'Doe' })
expect(draft.hasSecondaryPhone).toBe(false)
expect(draft.phoneSecondary).toBeNull()
})
})
describe('mapAddressToDraft', () => {
it('extrait les IRI des sites / categories / contacts embarques', () => {
const draft = mapAddressToDraft({
'@id': '/api/provider_addresses/3',
id: 3,
country: 'France',
postalCode: '86100',
city: 'Châtellerault',
street: '1 rue du Test',
sites: [{ '@id': '/api/sites/1' }],
categories: [{ '@id': '/api/categories/7' }],
contacts: [{ '@id': '/api/provider_contacts/5' }, '/api/provider_contacts/6'],
})
expect(draft.siteIris).toEqual(['/api/sites/1'])
expect(draft.categoryIris).toEqual(['/api/categories/7'])
expect(draft.contactIris).toEqual(['/api/provider_contacts/5', '/api/provider_contacts/6'])
expect(draft.id).toBe(3)
})
})
describe('mapAccountingDraft / mapRibToDraft', () => {
it('mappe les scalaires et les IRI des referentiels embarques', () => {
const draft = mapAccountingDraft({
'@id': '/api/providers/9',
id: 9,
siren: '123456789',
accountNumber: '4010',
nTva: 'FR123',
tvaMode: { '@id': '/api/tva_modes/1', label: 'TVA' },
paymentType: { '@id': '/api/payment_types/3', code: 'VIREMENT' },
bank: { '@id': '/api/banks/2' },
})
expect(draft.tvaModeIri).toBe('/api/tva_modes/1')
expect(draft.paymentTypeIri).toBe('/api/payment_types/3')
expect(draft.bankIri).toBe('/api/banks/2')
expect(draft.paymentDelayIri).toBeNull()
expect(draft.siren).toBe('123456789')
})
it('mappe un RIB embarque', () => {
expect(mapRibToDraft({ '@id': '/api/provider_ribs/1', id: 1, label: 'Compte', bic: 'BIC', iban: 'IBAN' }))
.toEqual({ id: 1, label: 'Compte', bic: 'BIC', iban: 'IBAN' })
})
})
describe('options builders (libelles role-independants depuis l\'embed)', () => {
it('categoryOptionsOf / siteOptionsOf / contactOptionsOf', () => {
expect(categoryOptionsOf([{ '@id': '/api/categories/7', name: 'Maintenance', code: 'MAINT' }]))
.toEqual([{ value: '/api/categories/7', label: 'Maintenance' }])
expect(siteOptionsOf([{ '@id': '/api/sites/1', name: 'Châtellerault' }]))
.toEqual([{ value: '/api/sites/1', label: 'Châtellerault' }])
expect(contactOptionsOf([{ '@id': '/api/provider_contacts/5', id: 5, firstName: 'Jean', lastName: 'Dupont' }]))
.toEqual([{ value: '/api/provider_contacts/5', label: 'Jean Dupont' }])
})
it('referentialOptionOf / paymentTypeCodeOf', () => {
expect(referentialOptionOf({ '@id': '/api/banks/2', label: 'SG' }))
.toEqual([{ value: '/api/banks/2', label: 'SG' }])
expect(referentialOptionOf(null)).toEqual([])
expect(referentialOptionOf('/api/banks/2')).toEqual([])
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/3', code: 'LCR' })).toBe('LCR')
expect(paymentTypeCodeOf(null)).toBeNull()
})
})
describe('actions selon permissions', () => {
/** Fabrique un `can` qui n'autorise que les codes fournis. */
const canFor = (granted: string[]) => (code: string) => granted.includes(code)
const canAnyFor = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c))
it('« Modifier » visible avec manage OU accounting.manage (Compta inclus)', () => {
expect(canEditProvider(canAnyFor(['technique.providers.manage']))).toBe(true)
expect(canEditProvider(canAnyFor(['technique.providers.accounting.manage']))).toBe(true)
expect(canEditProvider(canAnyFor(['technique.providers.view']))).toBe(false)
})
it('« Archiver » visible seulement avec archive ET prestataire actif (Admin seul)', () => {
const admin = canFor(['technique.providers.archive'])
const bureau = canFor(['technique.providers.manage'])
expect(showArchiveAction(admin, false)).toBe(true)
expect(showArchiveAction(admin, true)).toBe(false) // deja archive -> Restaurer
expect(showArchiveAction(bureau, false)).toBe(false) // pas la permission archive
})
it('« Restaurer » visible seulement avec archive ET prestataire archive', () => {
const admin = canFor(['technique.providers.archive'])
expect(showRestoreAction(admin, true)).toBe(true)
expect(showRestoreAction(admin, false)).toBe(false)
expect(showRestoreAction(canFor([]), true)).toBe(false)
})
})
})
@@ -1,86 +0,0 @@
/**
* Helpers purs de l'onglet Comptabilite prestataire (M3 Technique, ERP-144)
* miroir SIMPLIFIE des regles M2, reimplemente cote module Technique (regle
* ABSOLUE n°1 : pas d'import inter-module). Portent les RG inter-champs RG-3.07
* (banque si VIREMENT) et RG-3.08 (RIB si LCR), testables sans Vue ni API.
*/
import type {
ProviderAccountingDraft,
ProviderRibFormDraft,
} from '~/modules/technique/types/providerForm'
/** Code pivot du type de reglement imposant une banque (RG-3.07). */
const PAYMENT_TYPE_VIREMENT = 'VIREMENT'
/** Code pivot du type de reglement imposant au moins un RIB (RG-3.08). */
const PAYMENT_TYPE_LCR = 'LCR'
/** Champs RIB obligatoires non nullable cote back (NotBlank) — omis si vides au POST. */
const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const
/** Vrai si une chaine porte au moins un caractere non-espace. */
function isFilled(value: string | null | undefined): boolean {
return value !== null && value !== undefined && value.trim() !== ''
}
/** RG-3.07 : la banque n'est requise/visible que pour un reglement par VIREMENT. */
export function isBankRequiredForPaymentType(code: string | null | undefined): boolean {
return code === PAYMENT_TYPE_VIREMENT
}
/** RG-3.08 : au moins un RIB n'est requis que pour un reglement par LCR. */
export function isRibRequiredForPaymentType(code: string | null | undefined): boolean {
return code === PAYMENT_TYPE_LCR
}
/** Vrai si AUCUN champ du bloc RIB n'est rempli (amorce vide a ignorer au submit). */
export function isRibBlank(rib: ProviderRibFormDraft): boolean {
return ![rib.label, rib.bic, rib.iban].some(isFilled)
}
/** Vrai si les 3 champs du RIB sont remplis (gating « + RIB »). */
export function isRibComplete(rib: ProviderRibFormDraft): boolean {
return isFilled(rib.label) && isFilled(rib.bic) && isFilled(rib.iban)
}
/**
* Payload du PATCH comptable (groupe `provider:write:accounting`). Les relations
* sont en IRI ; la banque n'est envoyee que si elle est requise (RG-3.07), sinon
* `null` (le back vide la relation hors VIREMENT).
*/
export function buildProviderAccountingPayload(
accounting: ProviderAccountingDraft,
isBankRequired: boolean,
): Record<string, unknown> {
return {
siren: accounting.siren || null,
accountNumber: accounting.accountNumber || null,
tvaMode: accounting.tvaModeIri,
nTva: accounting.nTva || null,
paymentDelay: accounting.paymentDelayIri,
paymentType: accounting.paymentTypeIri,
bank: isBankRequired ? accounting.bankIri : null,
}
}
/**
* Payload d'un RIB (sous-ressource, groupe `provider:write:accounting`). Les
* champs requis vides sont omis a la creation pour que la 422 NotBlank porte sur
* le champ.
*/
export function buildProviderRibPayload(rib: ProviderRibFormDraft): Record<string, unknown> {
const payload: Record<string, unknown> = {
label: rib.label,
bic: rib.bic,
iban: rib.iban,
}
for (const key of RIB_REQUIRED_NON_NULLABLE_KEYS) {
const value = payload[key]
if (value === null || value === undefined || value === '') {
delete payload[key]
}
}
return payload
}
@@ -1,50 +0,0 @@
/**
* Helpers purs de l'onglet Adresse prestataire (M3 Technique, ERP-143) miroir
* SIMPLIFIE de `supplierFormRules`/`supplierEdit` (M2), reimplemente cote module
* Technique (regle ABSOLUE n°1 : pas d'import inter-module). Testables sans Vue.
*/
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
/**
* Champs scalaires obligatoires non nullable cote back (NotBlank). A la creation
* (POST), on OMET du payload ceux qui sont vides pour que la 422 porte la
* violation NotBlank propre (sur le champ) plutot qu'une erreur de type.
*/
const REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
/**
* RG-3.05 (+ RG-3.09) : une adresse est « valide » pour autoriser l'ajout d'un
* nouveau bloc des qu'elle porte au moins un site ET au moins une categorie. Les
* scalaires (CP/ville/rue) restent valides par le back (422 inline).
*/
export function isProviderAddressValid(address: ProviderAddressFormDraft): boolean {
return address.siteIris.length >= 1 && address.categoryIris.length >= 1
}
/**
* Payload de la sous-ressource addresses (groupe `provider:write:addresses`).
* Relations M2M en IRI. Les scalaires requis vides sont omis a la creation (cf.
* REQUIRED_NON_NULLABLE_KEYS).
*/
export function buildProviderAddressPayload(address: ProviderAddressFormDraft): Record<string, unknown> {
const payload: Record<string, unknown> = {
country: address.country,
postalCode: address.postalCode || null,
city: address.city || null,
street: address.street || null,
streetComplement: address.streetComplement || null,
categories: [...address.categoryIris],
sites: [...address.siteIris],
contacts: [...address.contactIris],
}
for (const key of REQUIRED_NON_NULLABLE_KEYS) {
const value = payload[key]
if (value === null || value === undefined || value === '') {
delete payload[key]
}
}
return payload
}
@@ -1,66 +0,0 @@
/**
* Helpers purs de l'onglet Contact prestataire (M3 Technique, ERP-142) miroir
* reduit de `supplierFormRules.ts` / `supplierEdit.ts` (M2). Testables sans Vue
* ni API : detection de bloc vide (RG-3.04) et construction du payload de
* sous-ressource contacts.
*/
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
/** Vrai si une chaine porte au moins un caractere non-espace. */
function isFilled(value: string | null | undefined): boolean {
return value !== null && value !== undefined && value.trim() !== ''
}
/**
* RG-3.04 : un bloc Contact est VIDE tant qu'aucun des champs comptant pour la
* validite n'est rempli prenom / nom / fonction / telephone principal / email.
*
* `phoneSecondary` est volontairement EXCLU : le back (CHECK
* `chk_provider_contact_name` + `ProviderContactProcessor`) ne le compte pas non
* plus, un bloc ne portant qu'un 2e numero reste invalide. Garder la meme
* definition cote front evite tout drift (un bloc « vide » front == bloc rejete
* back).
*/
export function isProviderContactBlank(contact: ProviderContactFormDraft): boolean {
return ![
contact.firstName,
contact.lastName,
contact.jobTitle,
contact.phonePrimary,
contact.email,
].some(isFilled)
}
/**
* RG-3.04 : un contact est « nomme » (valide) des qu'il porte un prenom OU un nom
* aligne sur le M1/M2. Sert le gating « + Nouveau contact » et la notion de
* contact valide (la fonction / le telephone / l'email seuls ne suffisent pas).
*/
export function isProviderContactNamed(contact: ProviderContactFormDraft): boolean {
return isFilled(contact.firstName) || isFilled(contact.lastName)
}
/**
* RG-3.12 : l'onglet Contact ne peut etre finalise que s'il reste au moins un
* contact nomme (prenom ou nom).
*/
export function hasAtLeastOneFilledContact(contacts: ProviderContactFormDraft[]): boolean {
return contacts.some(isProviderContactNamed)
}
/**
* Payload de la sous-ressource contacts (groupe `provider:write:contacts`). Les
* chaines vides sont envoyees a null (le serveur normalise/trim de toute facon).
* `phoneSecondary` n'est envoye que si le 2e numero a ete revele (max 2 tel).
*/
export function buildProviderContactPayload(contact: ProviderContactFormDraft): Record<string, unknown> {
return {
firstName: contact.firstName || null,
lastName: contact.lastName || null,
jobTitle: contact.jobTitle || null,
phonePrimary: contact.phonePrimary || null,
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
email: contact.email || null,
}
}
@@ -1,245 +0,0 @@
/**
* Helpers purs des ecrans Consultation / Modification prestataire (M3 Technique,
* ERP-145) miroir SIMPLIFIE de `supplierConsultation.ts` (M2). Mappent le payload
* `GET /api/providers/{id}` (relations embarquees, cf. groupes `provider:item:read`
* + `provider:read:accounting`) vers les brouillons « plats » partages avec
* `ProviderContactBlock` / `ProviderAddressBlock` et l'onglet Comptabilite.
*
* Ne touchent ni a l'API ni a l'etat reactif (testables unitairement).
*
* Rappels de contrat back (JSON reel fige ERP-139, spec-back § 4.0.bis) :
* - categories / sites du prestataire et des adresses : OBJETS embarques (avec @id) ;
* - refs comptables (tvaMode/paymentDelay/paymentType/bank) : OBJETS embarques
* `{@id, id, label, (code pour paymentType)}` ;
* - champs nuls OMIS (skip_null_values) toujours lire avec `?? null` ;
* - champs comptables + `ribs` TOTALEMENT ABSENTS sans permission accounting.view.
*
* Differences M2 : pas de type d'adresse / bennes / triage, pas d'onglet Information.
*/
import { formatPhoneFR } from '~/shared/utils/phone'
import type {
ProviderAccountingDraft,
ProviderAddressFormDraft,
ProviderContactFormDraft,
ProviderRibFormDraft,
} from '~/modules/technique/types/providerForm'
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
/** Reference Hydra embarquee minimale (@id toujours present). */
export interface HydraRef {
'@id': string
[key: string]: unknown
}
/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */
export type Relation = HydraRef | string | null | undefined
/** Site embarque (groupe site:read). */
export interface SiteRead extends HydraRef {
name?: string
postalCode?: string
color?: string
}
/** Categorie embarquee (groupe category:read). */
export interface CategoryRead extends HydraRef {
code?: string
name?: string
}
/** Contact embarque (groupe provider:item:read). */
export interface ContactRead extends HydraRef {
id: number
firstName?: string | null
lastName?: string | null
jobTitle?: string | null
phonePrimary?: string | null
phoneSecondary?: string | null
email?: string | null
}
/** Adresse embarquee (groupe provider:item:read) — version simplifiee M3. */
export interface AddressRead extends HydraRef {
id: number
country?: string | null
postalCode?: string | null
city?: string | null
street?: string | null
streetComplement?: string | null
sites?: SiteRead[]
categories?: CategoryRead[]
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
contacts?: Array<HydraRef | string>
}
/** RIB embarque (groupe provider:read:accounting, present ssi accounting.view). */
export interface RibRead extends HydraRef {
id: number
label?: string | null
bic?: string | null
iban?: string | null
}
/**
* Detail d'un prestataire (`GET /api/providers/{id}`). Tous les champs sont
* optionnels : skip_null_values + gating accounting peuvent omettre n'importe
* quelle cle.
*/
export interface ProviderDetail extends HydraRef {
id: number
companyName?: string | null
isArchived?: boolean
categories?: CategoryRead[]
sites?: SiteRead[]
contacts?: ContactRead[]
addresses?: AddressRead[]
ribs?: RibRead[]
// Onglet Comptabilite (present ssi accounting.view)
siren?: string | null
accountNumber?: string | null
nTva?: string | null
tvaMode?: Relation
paymentDelay?: Relation
paymentType?: Relation
bank?: Relation
}
/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */
export function iriOf(relation: Relation): string | null {
if (relation === null || relation === undefined) {
return null
}
if (typeof relation === 'string') {
return relation
}
return relation['@id'] ?? null
}
/** IRI des elements d'une collection embarquee (categories / sites du prestataire). */
export function irisOf(items: HydraRef[] | undefined): string[] {
return (items ?? []).map(i => i['@id'])
}
/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */
export function mapContactToDraft(contact: ContactRead): ProviderContactFormDraft {
const phoneSecondary = contact.phoneSecondary ?? null
return {
id: contact.id,
iri: contact['@id'] ?? null,
firstName: contact.firstName ?? null,
lastName: contact.lastName ?? null,
jobTitle: contact.jobTitle ?? null,
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
email: contact.email ?? null,
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
}
}
/** Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections). */
export function mapAddressToDraft(address: AddressRead): ProviderAddressFormDraft {
return {
id: address.id,
country: address.country ?? 'France',
postalCode: address.postalCode ?? null,
city: address.city ?? null,
street: address.street ?? null,
streetComplement: address.streetComplement ?? null,
categoryIris: (address.categories ?? []).map(c => c['@id']),
siteIris: (address.sites ?? []).map(s => s['@id']),
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
}
}
/** Mappe un RIB embarque vers un brouillon. */
export function mapRibToDraft(rib: RibRead): ProviderRibFormDraft {
return {
id: rib.id,
label: rib.label ?? null,
bic: rib.bic ?? null,
iban: rib.iban ?? null,
}
}
/** Mappe les champs comptables (scalaires + IRI des referentiels embarques). */
export function mapAccountingDraft(provider: ProviderDetail): ProviderAccountingDraft {
return {
siren: provider.siren ?? null,
accountNumber: provider.accountNumber ?? null,
nTva: provider.nTva ?? null,
tvaModeIri: iriOf(provider.tvaMode),
paymentDelayIri: iriOf(provider.paymentDelay),
paymentTypeIri: iriOf(provider.paymentType),
bankIri: iriOf(provider.bank),
}
}
/**
* Options de categories (value=IRI, label=nom) construites depuis l'embed.
* Source role-independante : evite de dependre de `GET /categories` (403 possible
* pour un role metier), qui laisserait les libelles vides en consultation.
*/
export function categoryOptionsOf(categories: CategoryRead[] | undefined): RefOption[] {
return (categories ?? []).map(c => ({
value: c['@id'],
label: c.name ?? c.code ?? c['@id'],
}))
}
/** Options de sites (value=IRI, label=nom) construites depuis un embed. */
export function siteOptionsOf(sites: SiteRead[] | undefined): RefOption[] {
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
}
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed prestataire. */
export function contactOptionsOf(contacts: ContactRead[] | undefined): RefOption[] {
return (contacts ?? []).map(c => ({
value: c['@id'],
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']),
}))
}
/**
* Liste a une seule option (ou vide) construite depuis un referentiel embarque
* (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en
* lecture seule. Le libelle vient de l'embed, jamais d'un GET de referentiel
* l'affichage reste correct quel que soit le role.
*/
export function referentialOptionOf(relation: Relation): RefOption[] {
if (!relation || typeof relation === 'string') {
return []
}
const label = (relation.label as string | undefined)
?? (relation.name as string | undefined)
?? relation['@id']
return [{ value: relation['@id'], label }]
}
/** Code metier d'un referentiel embarque (PaymentType.code = 'LCR' / 'VIREMENT'), ou null. */
export function paymentTypeCodeOf(relation: Relation): string | null {
if (!relation || typeof relation === 'string') {
return null
}
return (relation.code as string | undefined) ?? null
}
/**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
* `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir
* ouvrir l'edition pour son onglet Comptabilite). Le readonly fin par onglet est
* gere sur l'ecran d'edition.
*/
export function canEditProvider(canAny: (codes: string[]) => boolean): boolean {
return canAny(['technique.providers.manage', 'technique.providers.accounting.manage'])
}
/** Bouton « Archiver » : permission archive ET prestataire encore actif (Admin seul). */
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
return can('technique.providers.archive') && !isArchived
}
/** Bouton « Restaurer » : permission archive ET prestataire deja archive (Admin seul). */
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
return can('technique.providers.archive') && isArchived
}
@@ -1 +0,0 @@
export default defineNuxtConfig({})
@@ -1,121 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { removeCollectionRow, isRowRemovable, type DeletableRow } from '../collectionRow'
/**
* Tests de `removeCollectionRow` suppression d'une ligne de collection
* (contact / adresse / RIB) avec DELETE immediat de la sous-ressource existante
* (ERP-172). Coeur de logique mutualise par les 3 modules (Client / Fournisseur /
* Prestataire) : un seul comportement teste ici couvre les 9 cas (3 modules x 3
* blocs).
*/
interface Row extends DeletableRow {
label?: string
}
function makeEmpty(): Row {
return { id: null, label: '' }
}
describe('removeCollectionRow', () => {
it('emet un DELETE sur la sous-ressource quand le bloc est existant (id non null)', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
const errors: Record<string, string>[] = [{}, {}]
const deleteRow = vi.fn().mockResolvedValue(undefined)
const onError = vi.fn()
const removed = await removeCollectionRow({
rows, errors, index: 0,
endpoint: '/client_contacts',
deleteRow, makeEmpty, onError,
})
expect(deleteRow).toHaveBeenCalledOnce()
expect(deleteRow).toHaveBeenCalledWith('/client_contacts/10')
expect(removed).toBe(true)
expect(rows).toEqual([{ id: 11, label: 'B' }])
expect(errors).toHaveLength(1)
expect(onError).not.toHaveBeenCalled()
})
it('ne fait AUCUN appel reseau pour un bloc jamais persiste (id null) — retrait local', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: null, label: 'brouillon' }]
const errors: Record<string, string>[] = [{}, {}]
const deleteRow = vi.fn().mockResolvedValue(undefined)
const onError = vi.fn()
const removed = await removeCollectionRow({
rows, errors, index: 1,
endpoint: '/client_contacts',
deleteRow, makeEmpty, onError,
})
expect(deleteRow).not.toHaveBeenCalled()
expect(removed).toBe(true)
expect(rows).toEqual([{ id: 10, label: 'A' }])
})
it('conserve le bloc et remonte l\'erreur si le DELETE serveur echoue (ex. 409 dernier RIB LCR)', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
const errors: Record<string, string>[] = [{}, {}]
const error = { response: { status: 409 } }
const deleteRow = vi.fn().mockRejectedValue(error)
const onError = vi.fn()
const removed = await removeCollectionRow({
rows, errors, index: 0,
endpoint: '/client_ribs',
deleteRow, makeEmpty, onError,
})
expect(removed).toBe(false)
expect(onError).toHaveBeenCalledWith(error)
// Bloc NON retire : la suppression n'a pas ete confirmee par le serveur.
expect(rows).toEqual([{ id: 10, label: 'A' }, { id: 11, label: 'B' }])
expect(errors).toHaveLength(2)
})
it('garde au moins un bloc visible apres retrait du dernier (amorce vide)', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }]
const errors: Record<string, string>[] = [{}]
const deleteRow = vi.fn().mockResolvedValue(undefined)
await removeCollectionRow({
rows, errors, index: 0,
endpoint: '/client_contacts',
deleteRow, makeEmpty, onError: vi.fn(),
})
expect(rows).toEqual([{ id: null, label: '' }])
})
})
/**
* Tests de `isRowRemovable` la poubelle d'un bloc n'apparait que s'il reste un
* AUTRE bloc deja enregistre (id en base). Empeche de supprimer un bloc tant que
* rien n'est sauvegarde, et de supprimer son dernier bloc enregistre (ERP-172).
*/
describe('isRowRemovable', () => {
it('faux quand aucun autre bloc n\'est enregistre (que des brouillons)', () => {
const rows: Row[] = [{ id: null, label: 'brouillon 1' }, { id: null, label: 'brouillon 2' }]
expect(isRowRemovable(rows, 0)).toBe(false)
expect(isRowRemovable(rows, 1)).toBe(false)
})
it('faux pour le seul bloc enregistre (un brouillon a cote ne compte pas)', () => {
const rows: Row[] = [{ id: 10, label: 'enregistre' }, { id: null, label: 'brouillon' }]
// Le bloc enregistre ne peut pas etre supprime : aucun AUTRE bloc enregistre.
expect(isRowRemovable(rows, 0)).toBe(false)
// Le brouillon peut etre jete : il reste le bloc enregistre id=10.
expect(isRowRemovable(rows, 1)).toBe(true)
})
it('vrai pour chaque bloc des qu\'au moins deux sont enregistres', () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
expect(isRowRemovable(rows, 0)).toBe(true)
expect(isRowRemovable(rows, 1)).toBe(true)
})
it('faux pour un unique bloc', () => {
expect(isRowRemovable([{ id: 10, label: 'A' }], 0)).toBe(false)
})
})
-79
View File
@@ -1,79 +0,0 @@
/** Ligne de collection supprimable (contact / adresse / RIB). */
export interface DeletableRow {
id?: number | null
}
/**
* Indique si le bloc d'index `index` peut afficher sa poubelle (ERP-172).
*
* Regle metier : on ne peut supprimer un bloc QUE s'il reste au moins un AUTRE
* bloc deja enregistre (`id` non null, donc persiste en base). Consequences :
* - tant que rien n'est enregistre -> aucune poubelle (pas de suppression d'un
* simple brouillon saisi mais pas valide) ;
* - on peut jeter un brouillon non enregistre s'il reste un bloc enregistre ;
* - on ne peut jamais supprimer son dernier bloc enregistre.
*/
export function isRowRemovable<T extends DeletableRow>(rows: T[], index: number): boolean {
return rows.some((row, i) => i !== index && row.id != null)
}
/** Options de {@link removeCollectionRow}. */
export interface RemoveCollectionRowOptions<T extends DeletableRow> {
/** Tableau reactif des brouillons (passer le `.value` de la ref). */
rows: T[]
/** Tableau reactif des erreurs par ligne, aligne sur l'index (passer le `.value`). */
errors: Record<string, string>[]
/** Index de la ligne a retirer. */
index: number
/** Endpoint de la sous-ressource SANS id (ex: '/client_contacts'). */
endpoint: string
/** Suppression serveur : DOIT rejeter en cas d'echec (ex: url => api.delete(url, {}, { toast: false })). */
deleteRow: (url: string) => Promise<unknown>
/** Fabrique d'un bloc vide pour garder au moins un bloc visible apres retrait. */
makeEmpty: () => T
/** Remontee d'erreur 409/422 mappee proprement (message back, pas de toast fourre-tout). */
onError: (error: unknown) => void
}
/**
* Retire une ligne de collection (contact / adresse / RIB) sur les ecrans de
* MODIFICATION, avec DELETE immediat de la sous-ressource (ERP-172). Comportement
* aligne sur les 3 modules (Client / Fournisseur / Prestataire) :
*
* - Bloc jamais persiste (`id` null) : simple retrait local, aucun appel reseau.
* - Bloc existant (`id` non null) : DELETE `/endpoint/{id}` AVANT le retrait du
* tableau. On ne retire le bloc QUE si le serveur a confirme sinon le bloc
* reste affiche et l'erreur est remontee via `onError` (ex. dernier RIB d'une
* LCR -> 409 back, RG-x.08).
*
* Etat purement local : `rows`/`errors` sont les `.value` des refs (proxies
* reactifs), le `splice` declenche donc la reactivite.
*
* @returns `true` si la ligne a ete retiree (suppression confirmee ou bloc local),
* `false` si la suppression serveur a echoue (bloc conserve).
*/
export async function removeCollectionRow<T extends DeletableRow>(
options: RemoveCollectionRowOptions<T>,
): Promise<boolean> {
const { rows, errors, index, endpoint, deleteRow, makeEmpty, onError } = options
const removed = rows[index]
// Bloc existant : suppression serveur d'abord, retrait local seulement si OK.
if (removed?.id != null) {
try {
await deleteRow(`${endpoint}/${removed.id}`)
}
catch (error) {
onError(error)
return false
}
}
rows.splice(index, 1)
errors.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (rows.length === 0) {
rows.push(makeEmpty())
}
return true
}
-18
View File
@@ -84,24 +84,6 @@ export const personas: Record<PersonaKey, Persona> = {
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
'commercial.suppliers.archive',
// Technique — Repertoire prestataires (M3, ERP-138). Meme logique que
// clients/fournisseurs : mappe sur le persona "tout", pas de nouveau
// persona (regle ABSOLUE n°7). user-full porte deja sites.bypass_scope,
// donc il voit les prestataires de tous les sites (M3 § 2.13).
// technique.providers.view n'ajoute pas de lien dans la section
// Administration, donc expectedAdminLinks reste inchange.
'technique.providers.view',
'technique.providers.manage',
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
'technique.providers.archive',
// 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'],
},
-18
View File
@@ -231,8 +231,6 @@ test-db-setup:
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL"
$(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
@@ -251,22 +249,6 @@ sync-permissions:
seed-rbac:
$(SYMFONY_CONSOLE) --no-interaction app:seed-rbac
# Synchronise le referentiel des transporteurs QUALIMAT (ERP-39) : upsert sur
# le SIRET + soft-delete des absents + journal. Idempotent (refresh complet),
# prevu pour un cron quotidien.
# Options : --dry-run (analyse sans ecriture), --file=<chemin.json> (source
# locale au lieu de l'API), --ppp=<n> (taille de page API, defaut 10000).
qualimat-sync:
$(SYMFONY_CONSOLE) --no-interaction app:qualimat:sync
# Synchronise le referentiel des codes IDTF (ERP-149) depuis l'export Excel
# icrt-idtf.com : upsert sur (schema, idtf_number) + soft-delete + journal.
# Idempotent (refresh complet).
# Options : --schema=road|water (defaut road), --dry-run (analyse sans
# ecriture), --file=<chemin.xlsx> (source locale au lieu du telechargement).
idtf-sync:
$(SYMFONY_CONSOLE) --no-interaction app:idtf:sync
# Attention, supprime votre bdd local
db-reset:
$(DOCKER_COMPOSE) down -v
+3 -3
View File
@@ -252,7 +252,7 @@ final class Version20260612100000 extends AbstractMigration
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT chk_provider_contact_name
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL),
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL),
CONSTRAINT fk_provider_contact_provider
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_contact_created_by
@@ -263,12 +263,12 @@ final class Version20260612100000 extends AbstractMigration
SQL);
$this->addSql('CREATE INDEX idx_provider_contact_provider ON provider_contact (provider_id)');
$this->comment('provider_contact', '_table', 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/fonction/telephone/email (RG-3.04, chk_provider_contact_name).');
$this->comment('provider_contact', '_table', 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/telephone/email (RG-3.04, chk_provider_contact_name).');
$this->comment('provider_contact', 'id', 'Identifiant interne auto-incremente.');
$this->comment('provider_contact', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.');
$this->comment('provider_contact', 'first_name', 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
$this->comment('provider_contact', 'last_name', 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
$this->comment('provider_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
$this->comment('provider_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).');
$this->comment('provider_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (normalisation serveur).');
$this->comment('provider_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).');
$this->comment('provider_contact', 'email', 'Email du contact (lowercase serveur).');
-120
View File
@@ -1,120 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-149 (Module Transport) : referentiel des codes IDTF (regimes de nettoyage
* transport).
*
* Tables alimentees par la commande `app:idtf:sync` (parsing de l'export Excel
* icrt-idtf.com, upsert sur (schema, idtf_number) + soft-delete + journal).
* Aucune FK cross-module : migration au namespace racine `DoctrineMigrations`.
*/
final class Version20260612160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-149 : tables idtf_product + idtf_sync_log (referentiel codes IDTF, synchro console depuis l\'export Excel).';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE idtf_product (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
idtf_number INTEGER NOT NULL,
schema VARCHAR(8) NOT NULL,
product_group VARCHAR(255) DEFAULT NULL,
name TEXT NOT NULL,
cleaning_regime VARCHAR(64) NOT NULL,
important_requirements TEXT DEFAULT NULL,
mandatory_date DATE DEFAULT NULL,
related_products TEXT DEFAULT NULL,
formula VARCHAR(255) DEFAULT NULL,
eural_code VARCHAR(64) DEFAULT NULL,
cas_numbers JSONB DEFAULT '[]' NOT NULL,
footnotes TEXT DEFAULT NULL,
source_export_date DATE NOT NULL,
is_active BOOLEAN DEFAULT TRUE NOT NULL,
last_synced_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uq_idtf_product_schema_number UNIQUE (schema, idtf_number),
CONSTRAINT chk_idtf_product_schema CHECK (schema IN ('road', 'water'))
)
SQL);
$this->addSql('CREATE INDEX idx_idtf_product_active ON idtf_product (schema, is_active)');
$this->comment('idtf_product', '_table', "Referentiel des codes IDTF (marchandise + regime de nettoyage transport), synchronise depuis l'export Excel icrt-idtf.com.");
$this->comment('idtf_product', 'id', 'Cle technique auto-incrementee.');
$this->comment('idtf_product', 'idtf_number', 'Numero IDTF de la marchandise (identifiant metier source). Unique par schema.');
$this->comment('idtf_product', 'schema', "Mode de transport / schema IDTF : 'road' (routier) ou 'water' (fluvial). Discriminant d'unicite avec idtf_number.");
$this->comment('idtf_product', 'product_group', "Groupe de produit (colonne Product Group de l'export). Nullable.");
$this->comment('idtf_product', 'name', "Nom de la marchandise (libelle FR de l'export).");
$this->comment('idtf_product', 'cleaning_regime', 'Regime de nettoyage minimal exige (A, B, C, Interdit, ...).');
$this->comment('idtf_product', 'important_requirements', 'Exigences importantes associees. Nullable.');
$this->comment('idtf_product', 'mandatory_date', "Date d'application obligatoire du regime (convertie depuis dd-mm-yyyy). Nullable.");
$this->comment('idtf_product', 'related_products', 'Produits apparentes (texte libre). Nullable.');
$this->comment('idtf_product', 'formula', 'Formule chimique de la marchandise. Nullable.');
$this->comment('idtf_product', 'eural_code', 'Code EURAL (dechet) associe. Nullable.');
$this->comment('idtf_product', 'cas_numbers', 'Liste des numeros CAS (JSONB), eclatee depuis la cellule "Numero CAS" separee par ";". Tableau vide si absent.');
$this->comment('idtf_product', 'footnotes', "Annotations / notes de bas de page de l'export. Nullable.");
$this->comment('idtf_product', 'source_export_date', 'Date d\'export du fichier source (preambule "Export date:").');
$this->comment('idtf_product', 'is_active', 'Faux = ligne absente du dernier export (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.');
$this->comment('idtf_product', 'last_synced_at', 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).');
$this->addSql(<<<'SQL'
CREATE TABLE idtf_sync_log (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
schema VARCHAR(8) NOT NULL,
export_date DATE NOT NULL,
rows_total INT NOT NULL,
rows_upserted INT NOT NULL,
rows_deactivated INT NOT NULL,
created_at TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW() NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->comment('idtf_sync_log', '_table', 'Journal des synchronisations IDTF (une ligne par run de la commande app:idtf:sync).');
$this->comment('idtf_sync_log', 'id', 'Cle technique auto-incrementee.');
$this->comment('idtf_sync_log', 'schema', "Mode de transport synchronise : 'road' ou 'water'.");
$this->comment('idtf_sync_log', 'export_date', "Date d'export du fichier source traite par ce run.");
$this->comment('idtf_sync_log', 'rows_total', 'Nombre de lignes exploitables lues dans le fichier.');
$this->comment('idtf_sync_log', 'rows_upserted', 'Nombre de lignes inserees ou mises a jour.');
$this->comment('idtf_sync_log', 'rows_deactivated', 'Nombre de lignes passees a is_active=false (absentes de cet export).');
$this->comment('idtf_sync_log', 'created_at', 'Horodatage de fin du run (insertion du journal).');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS idtf_sync_log');
$this->addSql('DROP TABLE IF EXISTS idtf_product');
}
/**
* Pose un COMMENT ON TABLE/COLUMN en dollar-quoting Postgres ($_$...$_$)
* pour eviter tout echappement d'apostrophes dans les descriptions.
*/
private function comment(string $table, string $column, string $description): void
{
$quotedTable = '"'.str_replace('"', '""', $table).'"';
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
return;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
-50
View File
@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* RG-3.04 (correctif) aligne la regle de validite d'un contact prestataire sur
* le M1/M2 : au moins le PRENOM OU le NOM (et non plus « un champ quelconque parmi
* prenom/nom/fonction/telephone/email »). Remplace le CHECK chk_provider_contact_name
* et met a jour les commentaires de colonnes. La garde applicative
* (ProviderContactProcessor::validateName) est alignee dans le meme commit.
*
* Placee au namespace racine DoctrineMigrations (et non en modulaire Technique) :
* elle ALTERE une table creee par une migration racine (Version20260612100000) ;
* le tri par version au sein du meme namespace garantit qu'elle joue APRES l'init
* (cf. CLAUDE.md regle 11 le tri cross-namespace casserait l'ordre sur base vide).
*/
final class Version20260615120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'RG-3.04 : contact prestataire valide si prenom OU nom (alignement M1/M2) — CHECK chk_provider_contact_name.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name');
$this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)');
$this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).$_$');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name');
$this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL)');
$this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/fonction/telephone/email (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
}
}
-86
View File
@@ -1,86 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-154 Infra d'upload de fichiers generique et reutilisable (src/Shared).
*
* Cree la table `uploaded_document` : reference technique d'un fichier televerse
* (PDF / image), gere par le service Shared\Infrastructure\Upload\FileUploader.
* La « Decharge » du M4 transporteurs en sera le premier consommateur, mais ce
* ticket ne touche AUCUN module : la table vit cote Shared.
*
* Caracteristiques :
* - Document IMMUABLE : pas d'onglet edition, pas de updated_at / updated_by.
* Seules les colonnes created_at (UTC, remplie par le FileUploader via
* l'horloge injectee) et created_by (auteur HTTP, null hors HTTP) tracent
* l'origine. C'est pourquoi l'entite Shared n'implemente PAS
* Timestampable/Blamable (qui imposeraient les 4 colonnes).
* - checksum sha256 (64 caracteres hex) : controle d'integrite + future
* deduplication eventuelle (hors scope ici).
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et non
* modulaire : la table porte une FK cross-module vers "user" (created_by). Le
* tri par version au sein du namespace racine garantit qu'elle joue APRES la
* creation de "user" sur base vide.
*
* Style DDL aligne sur le M1/M2/M3 : `INT GENERATED BY DEFAULT AS IDENTITY` et
* `TIMESTAMP(0) WITHOUT TIME ZONE` (mapping ORM `datetime_immutable`), pour que
* `schema:update --force` reste un no-op une fois l'entite mappee.
*
* COMMENT ON COLUMN inline (regle ABSOLUE n°12) : chaque colonne porte sa
* description ici. La table est aussi ajoutee a `ColumnCommentsCatalog` car
* l'entite UploadedDocument existe des ce ticket `app:apply-column-comments`
* du `test-db-setup` rejoue donc ces COMMENT apres le `schema:update --force`.
*/
final class Version20260615130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-154 : table uploaded_document (infra upload generique Shared) — fichier televerse immuable, checksum sha256.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE uploaded_document (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
original_filename VARCHAR(255) NOT NULL,
stored_path VARCHAR(512) NOT NULL,
mime_type VARCHAR(100) NOT NULL,
size_bytes INT NOT NULL,
checksum VARCHAR(64) NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_uploaded_document_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
// Postgres n'indexe pas automatiquement les colonnes de FK.
$this->addSql('CREATE INDEX idx_uploaded_document_created_by ON uploaded_document (created_by)');
// Recherche d'integrite / future deduplication par empreinte sha256.
$this->addSql('CREATE INDEX idx_uploaded_document_checksum ON uploaded_document (checksum)');
$this->addSql('COMMENT ON TABLE uploaded_document IS $_$Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.id IS $_$Identifiant interne auto-incremente.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.original_filename IS $_$Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.stored_path IS $_$Chemin relatif du fichier sous var/uploads (ex: 2026/06/<hash>.pdf) — nom genere aleatoirement, jamais le nom client.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.mime_type IS $_$Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.size_bytes IS $_$Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.checksum IS $_$Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.created_at IS $_$Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.created_by IS $_$ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.$_$');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS uploaded_document');
}
}
-356
View File
@@ -1,356 +0,0 @@
<?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,
));
}
}
+4 -5
View File
@@ -21,8 +21,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
* Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 § 4.0) ;
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 § 4.0.bis).
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 § 4.0).
*/
#[ApiResource(
operations: [
@@ -49,15 +48,15 @@ class Bank
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
@@ -15,7 +15,6 @@ 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;
@@ -148,7 +147,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, ClientInterface
class Client implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
@@ -15,7 +15,6 @@ 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;
@@ -90,7 +89,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, ClientAddressInterface
class ClientAddress implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
@@ -21,8 +21,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
* Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 § 4.0) ;
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 § 4.0.bis).
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 § 4.0).
*/
#[ApiResource(
operations: [
@@ -49,15 +48,15 @@ class PaymentDelay
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
@@ -24,8 +24,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
* Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 § 4.0) ;
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 § 4.0.bis).
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 § 4.0).
*/
#[ApiResource(
operations: [
@@ -52,15 +51,15 @@ class PaymentType
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
@@ -16,7 +16,6 @@ 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;
@@ -143,7 +142,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, SupplierInterface
class Supplier implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
@@ -16,7 +16,6 @@ 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;
@@ -97,7 +96,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, SupplierAddressInterface
class SupplierAddress implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
@@ -118,11 +117,7 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
// 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'])]
#[Groups(['supplier:item:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Supplier::class, inversedBy: 'addresses')]
@@ -135,12 +130,12 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
#[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', 'supplier_address:read'])]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
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', 'supplier_address:read'])]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
private string $country = 'France';
// RG-2.05 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur).
@@ -148,24 +143,24 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
#[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', 'supplier_address:read'])]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
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', 'supplier_address:read'])]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
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', 'supplier_address:read'])]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
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', 'supplier_address:read'])]
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
private ?string $streetComplement = null;
// Specifique fournisseur : nombre de bennes sur le site.
@@ -25,8 +25,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
* d'un Client (onglet Comptabilite) au lieu d'un IRI ; `supplier:read:accounting`
* fait de meme dans la reponse Fournisseur (M2, ERP-92 sinon IRI nu, § 4.0) ;
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 § 4.0.bis).
* fait de meme dans la reponse Fournisseur (M2, ERP-92 sinon IRI nu, § 4.0).
*/
#[ApiResource(
operations: [
@@ -56,15 +55,15 @@ class TvaMode
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
@@ -50,18 +50,10 @@ 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`,
* `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 /
* Commerciale (ils voient « Tout », d'apres le docx) ; Usine ne l'a PAS et
* reste cloisonnee a son site courant. Admin a le bypass total via isAdmin.
* C'est un cloisonnement pilote par user/permission, pas par code de role :
* pour cloisonner Bureau/Commerciale, il suffit de retirer la permission
* ici, aucun autre code a changer.
* attacher (vide pour usine : aucun acces ; admin n'apparait pas car il
* bypass tout via isAdmin ; `commercial.clients.archive` et
* `commercial.suppliers.archive` ne sont attaches a aucun role metier
* admin seul).
*
* @var array<string, array{label: string, permissions: list<string>}>
*/
@@ -74,14 +66,6 @@ final class RbacSeeder
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
'commercial.suppliers.view',
'commercial.suppliers.manage',
// Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite).
'technique.providers.view',
'technique.providers.manage',
// 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).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -98,13 +82,6 @@ final class RbacSeeder
'commercial.suppliers.view',
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
// Prestataires (M3 § 2.9, ERP-138) : view + onglet Comptabilite uniquement
// (pas de manage global -> ne peut pas creer un prestataire).
'technique.providers.view',
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -119,28 +96,14 @@ final class RbacSeeder
// (onglet Comptabilite masque/filtre pour la Commerciale).
'commercial.suppliers.view',
'commercial.suppliers.manage',
// Prestataires (M3 § 2.9, ERP-138) : view + manage, sans accounting
// (onglet Comptabilite masque/filtre pour la Commerciale).
'technique.providers.view',
'technique.providers.manage',
// 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).
'catalog.categories.read_ref',
'sites.read_ref',
],
],
self::ROLE_USINE => [
'label' => 'Usine',
// Prestataires (M3 § 2.9 + § 2.13, ERP-138) : view en lecture seule,
// SANS `sites.bypass_scope` -> cloisonne aux prestataires de son site
// courant. Aucun autre acces metier.
'permissions' => [
'technique.providers.view',
],
'label' => 'Usine',
'permissions' => [],
],
];
@@ -203,20 +203,6 @@ final class SeedE2ECommand extends Command
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
'commercial.suppliers.archive',
// Technique — Repertoire prestataires (M3, ERP-138). Meme
// logique : mappe sur le persona "tout". user-full porte deja
// sites.bypass_scope -> voit les prestataires de tous les
// sites (M3 § 2.13). Miroir de personas.ts.
'technique.providers.view',
'technique.providers.manage',
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
'technique.providers.archive',
// 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',
],
],
[
@@ -1,99 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Application\Service;
/**
* Normalisation serveur des champs texte d'un Provider / ProviderContact,
* appliquee par le ProviderProcessor (et les processors de sous-ressources,
* ticket ulterieur M3) AVANT persistance. Cf. spec-back M3 § 2.11 + RG-3.11.
* Jumeau de SupplierFieldNormalizer (M2) duplique volontairement (isolation
* Commercial / Technique, decision § 2.1).
*
* - companyName : UPPERCASE integral (RG-3.11)
* - firstName / lastName (personnes, sur ProviderContact) : Title Case (RG-3.11)
* - phone* : chiffres uniquement, ex "06.12.34.56.78" -> "0612345678" (RG-3.11).
* Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front.
* - email : lowercase integral (RG-3.11)
*
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide
* apres trim devient null (evite de persister "" dans des colonnes nullable).
*/
final class ProviderFieldNormalizer
{
/**
* Nom de societe en majuscules (RG-3.11). Conserve null tel quel ; une
* chaine non vide est trim + upper. Une chaine vide reste "" (champ
* obligatoire : c'est l'Assert\NotBlank qui rejette, pas le normalizer).
*/
public function normalizeCompanyName(?string $value): ?string
{
if (null === $value) {
return null;
}
return mb_strtoupper(trim($value), 'UTF-8');
}
/**
* Nom/prenom de personne en Title Case (RG-3.11) : "JEAN dupont" ->
* "Jean Dupont". Une chaine vide apres trim devient null.
*/
public function normalizePersonName(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8');
}
/**
* Email en minuscules (RG-3.11). Une chaine vide apres trim devient null.
*/
public function normalizeEmail(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
}
/**
* Texte libre simplement trim (ex : jobTitle / Fonction du contact). Pas de
* changement de casse on preserve la saisie. Une chaine vide apres trim
* devient null (evite de persister "" et de faire passer a tort le garde-fou
* RG-3.04 / le CHECK chk_provider_contact_name sur une Fonction vide).
*/
public function normalizeText(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : $value;
}
/**
* Telephone reduit aux chiffres (RG-3.11) : "06.12.34.56.78" ->
* "0612345678". Une valeur sans aucun chiffre devient null.
*/
public function normalizePhone(?string $value): ?string
{
if (null === $value) {
return null;
}
$digits = preg_replace('/\D+/', '', $value) ?? '';
return '' === $digits ? null : $digits;
}
}
@@ -1,79 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Application\Validator;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Technique\Domain\Entity\Provider;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier (spec-front M3 § Onglet Comptabilite jumeau de
* SupplierAccountingCompletenessValidator M2) : a la soumission complete de
* l'onglet Comptabilite, les six champs scalaires obligatoires doivent etre
* renseignes (SIREN, Numero de compte, Mode de TVA, N de TVA, Delai de reglement,
* Type de reglement). La banque reste conditionnelle (RG-3.07) et les RIB aussi
* (RG-3.08) : ils ne sont pas couverts ici (Assert\Callback sur l'entite Provider
* validatePaymentTypeConsistency).
*
* Parti pris (miroir M1/M2) : colonnes nullable en base + validateur contextuel,
* plutot qu'un Assert\NotBlank sur l'entite (qui casserait le POST de l'onglet
* principal, lequel n'envoie aucun champ comptable).
*
* Invoque par le ProviderProcessor uniquement quand le payload porte les six
* champs (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ.
*
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
* coherence avec les violations Symfony rendues par API Platform (mapping inline
* front via useFormErrors, ERP-101).
*/
final class ProviderAccountingCompletenessValidator
{
public function validate(Provider $provider): void
{
// Map champ -> valeur courante des champs obligatoires de l'onglet.
$fields = [
'siren' => $provider->getSiren(),
'accountNumber' => $provider->getAccountNumber(),
'tvaMode' => $provider->getTvaMode(),
'nTva' => $provider->getNTva(),
'paymentDelay' => $provider->getPaymentDelay(),
'paymentType' => $provider->getPaymentType(),
];
$violations = new ConstraintViolationList();
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
'Ce champ est obligatoire.',
null,
[],
$provider,
$property,
$value,
));
}
}
if (count($violations) > 0) {
throw new ValidationException($violations);
}
}
/**
* Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les
* references (TvaMode / PaymentDelay / PaymentType) ne sont manquantes que
* lorsqu'elles valent null.
*/
private function isMissing(mixed $value): bool
{
if (null === $value) {
return true;
}
return is_string($value) && '' === trim($value);
}
}
@@ -1,607 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\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\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderProvider;
use App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
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;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Prestataire (M3 Technique) entite racine du repertoire prestataires, jumelle
* du Fournisseur (M2). Porte le formulaire principal (nom + categories + sites),
* l'onglet Comptabilite, le mecanisme d'archivage (is_archived / archived_at) et
* le soft-delete technique prepare mais non expose au M3 (deleted_at, HP M4).
*
* Differences structurantes vs Supplier (cf. spec M3 § 3.1) :
* - PAS d'onglet Information : aucun champ description / competitors / founded_at
* / employees_count / revenue_amount / director_name / profit_amount /
* volume_forecast. Le prestataire est minimal : nom + comptabilite.
* - AJOUT de `sites` (M2M `provider_site`) : sites rattaches DIRECTEMENT au
* prestataire sur le formulaire principal (RG-3.03, >= 1). Nouveau vs supplier
* (qui n'avait des sites que sur l'adresse). Sert aussi le cloisonnement par
* site (§ 2.13, ticket Provider/Processor ERP-134).
*
* Referentiels comptables (TvaMode / PaymentDelay / PaymentType / Bank) et Site /
* Category : consommes en RELATION ORM PARTAGEE (decision Matthieu, § 2.1). Site /
* Category passent par les contrats Shared (SiteInterface / CategoryInterface +
* resolve_target_entities) comme le fait deja Supplier (regle ABSOLUE n°1). Les 4
* referentiels comptables vivent dans le module Commercial et sont references en
* direct, faute de contrat Shared dedie (remontee dans Shared tracee HP-M4-2)
* reference de donnees de reference, pas de logique inter-module.
*
* Contrat de serialisation (RETEX M1, 3 maillons spec § 4.0) : les read-groups
* sont poses ICI (source unique). L'#[ApiResource] cable (ERP-134) le ProviderProvider
* (liste paginee anti-N+1, exclusion archives, cloisonnement site lecture + detail
* 404) et le ProviderProcessor (normalisation, archivage, mode strict par groupe,
* cloisonnement site ecriture, 409 doublon). Le groupe provider:read:accounting est
* ajoute dynamiquement au contexte par le ProviderReadGroupContextBuilder selon la
* permission accounting.view (ERP-134) jamais pose en dur sur l'operation.
*
* Audite (#[Auditable], tous champs — y compris RIB embarques, § 2.7) +
* Timestampable / Blamable via le trait Shared.
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('technique.providers.view')",
// La liste embarque les categories (code/name, groupe category:read) et
// les sites du prestataire (name/postalCode, groupe site:read — relation
// DIRECTE provider.sites, RG-3.03). Maillon (c) : category:read +
// site:read presents dans le contexte. Hydratation anti-N+1 cablee par
// le ProviderProvider (cf. DoctrineProviderRepository::hydrateListCollections).
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
provider: ProviderProvider::class,
),
new Get(
security: "is_granted('technique.providers.view')",
// Detail : prestataire + sous-collections embarquees (contacts, adresses
// + leurs sites/categories/contacts) + RIB (gates compta). Le groupe
// provider:read:accounting est volontairement ABSENT : il est ajoute au
// contexte par le ProviderReadGroupContextBuilder selon la permission
// accounting.view (parade fuite IBAN/BIC — bug #4 M1).
normalizationContext: ['groups' => [
'provider:read',
'provider:item:read',
'category:read',
'site:read',
'default:read',
]],
provider: ProviderProvider::class,
),
new Post(
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
denormalizationContext: ['groups' => ['provider:write:main']],
processor: ProviderProcessor::class,
),
new Patch(
// Security elargie : `manage` OU `accounting.manage` — le role Compta n'a
// pas `manage` global mais doit pouvoir editer l'onglet Comptabilite d'un
// prestataire existant (§ 2.9). Le re-gating onglet par onglet (mode strict
// RG-3.15) est porte par le ProviderProcessor (ERP-134).
security: "is_granted('technique.providers.manage') or is_granted('technique.providers.accounting.manage')",
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
denormalizationContext: ['groups' => [
'provider:write:main',
'provider:write:accounting',
'provider:write:archive',
]],
provider: ProviderProvider::class,
processor: ProviderProcessor::class,
),
// Pas de Delete au M3 (HP M4). Archivage via PATCH { isArchived: true }.
],
)]
#[ORM\Entity(repositoryClass: DoctrineProviderRepository::class)]
#[ORM\Table(name: 'provider')]
// Index nommes pour matcher la migration (Version20260612100000). L'index unique
// partiel uq_provider_company_name_active reste possede par la migration : Doctrine
// ORM ne sait pas exprimer un index fonctionnel (LOWER) + partiel (WHERE) via
// attribut. Pas de #[ORM\UniqueConstraint] (§ 2.6).
#[ORM\Index(name: 'idx_provider_is_archived', columns: ['is_archived'])]
#[ORM\Index(name: 'idx_provider_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_provider_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_provider_updated_by', columns: ['updated_by'])]
#[Auditable]
class Provider implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
/**
* RG-3.09 : seules les categories PORTANT ce type sont autorisees sur le
* prestataire (entite principale) ET sur ses adresses. Miroir de
* ProviderAddress. S'appuie sur CategoryInterface::getCategoryTypeCodes()
* (pas d'import du module Catalog regle ABSOLUE n°1).
*/
private const string REQUIRED_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
/** Code pivot du type de reglement imposant une banque (RG-3.07). */
private const string PAYMENT_TYPE_VIREMENT = 'VIREMENT';
/** Code pivot du type de reglement imposant au moins un RIB (RG-3.08). */
private const string PAYMENT_TYPE_LCR = 'LCR';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['provider:read'])]
private ?int $id = null;
// === Formulaire principal ===
#[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'Le nom du prestataire est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du prestataire doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du prestataire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:read', 'provider:write:main'])]
private ?string $companyName = null;
// RG-3.09 : au moins une categorie (Count min 1), de type PRESTATAIRE (verifie
// par validateCategoryType). M2M vers Category via le contrat CategoryInterface
// (resolve_target_entities -> Category). Embarquee en LISTE ET DETAIL ; maillon
// (c) : le contexte inclut 'category:read' pour exposer id/code/name.
/** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'provider_category')]
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
#[Groups(['provider:read', 'provider:write:main'])]
private Collection $categories;
// RG-3.03 (SPECIFICITE M3) : au moins un site (Count min 1). Sites rattaches
// DIRECTEMENT au prestataire sur le formulaire principal (le fournisseur n'avait
// des sites que sur l'adresse). M2M vers Site via le contrat SiteInterface
// (resolve_target_entities -> Site). Embarquee en LISTE ET DETAIL ; maillon (c) :
// le contexte inclut 'site:read' pour exposer name/postalCode (Site n'a pas de
// `code`). L'ecriture cloisonnee par user_site (§ 2.13) est portee par le
// ProviderProcessor (ERP-134).
/** @var Collection<int, SiteInterface> */
#[ORM\ManyToMany(targetEntity: SiteInterface::class)]
#[ORM\JoinTable(name: 'provider_site')]
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')]
#[Groups(['provider:read', 'provider:write:main'])]
private Collection $sites;
// === Onglet Comptabilite ===
// Lecture conditionnee via le groupe `provider:read:accounting` (ajoute au
// contexte par le ProviderProvider / ReadGroupContextBuilder si l'user a
// accounting.view — ERP-134). Ecriture via `provider:write:accounting` (le
// Processor exige accounting.manage).
#[ORM\Column(length: 20, nullable: true)]
#[Assert\Length(max: 20, maxMessage: 'Le SIREN ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $siren = null;
#[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $accountNumber = null;
#[ORM\ManyToOne(targetEntity: TvaMode::class)]
#[ORM\JoinColumn(name: 'tva_mode_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?TvaMode $tvaMode = null;
#[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $nTva = null;
#[ORM\ManyToOne(targetEntity: PaymentDelay::class)]
#[ORM\JoinColumn(name: 'payment_delay_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?PaymentDelay $paymentDelay = null;
#[ORM\ManyToOne(targetEntity: PaymentType::class)]
#[ORM\JoinColumn(name: 'payment_type_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?PaymentType $paymentType = null;
#[ORM\ManyToOne(targetEntity: Bank::class)]
#[ORM\JoinColumn(name: 'bank_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?Bank $bank = null;
// === Sous-collections — EMBARQUEES dans le DETAIL (RETEX M1 §2) ===
// Maillon (a) : le read-group est porte par le GETTER (getContacts / getAddresses
// / getRibs) — sans #[Groups], jamais serialisees. Edition via sous-ressources
// (ticket ulterieur M3).
/** @var Collection<int, ProviderContact> */
#[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contacts;
/** @var Collection<int, ProviderAddress> */
#[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $addresses;
/** @var Collection<int, ProviderRib> */
#[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $ribs;
// === Archive / Soft delete ===
// Groupe d'ECRITURE uniquement sur la propriete (denormalisation PATCH archive).
// Le groupe de LECTURE est declare sur le getter isArchived() avec
// SerializedName('isArchived') : sans cela, Symfony strip le prefixe "is" et
// exposerait la cle JSON "archived" — en pratique la cle est totalement DROPPEE
// (piege n°3 du M1). Pattern corrige : Groups + SerializedName sur le getter.
#[ORM\Column(name: 'is_archived', options: ['default' => false])]
#[Groups(['provider:write:archive'])]
private bool $isArchived = false;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['provider:read'])]
private ?DateTimeImmutable $archivedAt = null;
// Soft delete technique (HP M4) : non expose en lecture/ecriture au M3.
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $deletedAt = null;
public function __construct()
{
$this->categories = new ArrayCollection();
$this->sites = new ArrayCollection();
$this->contacts = new ArrayCollection();
$this->addresses = new ArrayCollection();
$this->ribs = new ArrayCollection();
}
/**
* RG-3.09 : toute categorie posee sur le prestataire doit etre de type
* PRESTATAIRE -> sinon 422 avec violation sur le champ `categories`
* (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
* ProviderAddress::validateCategoryType. S'appuie sur
* CategoryInterface::getCategoryTypeCodes() (multi-type la categorie est
* acceptee des qu'elle PORTE le type PRESTATAIRE ; pas d'import du module
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API
* Platform, sur POST (categories provider:write:main) comme sur PATCH.
*/
#[Assert\Callback]
public function validateCategoryType(ExecutionContextInterface $context): void
{
foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
$context->buildViolation('Type de catégorie non autorisé (PRESTATAIRE attendu).')
->atPath('categories')
->addViolation()
;
return;
}
}
}
/**
* RG-3.07 / RG-3.08 : coherence du type de reglement comptable. Comme au M2
* (decision figee ERP-89, jumeau Supplier::validatePaymentTypeConsistency),
* ces RG inter-champs passent par une contrainte d'entite (Assert\Callback +
* ->atPath()) et NON par le ProviderProcessor, afin que chaque 422 porte un
* propertyPath exploitable par extractApiViolations (mapping inline sous le
* champ, pas un toast convention ERP-101).
* - RG-3.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`.
* - RG-3.08 : paymentType = LCR impose au moins un RIB -> violation sur
* `paymentType` (les RIB n'ont pas de champ de formulaire ou s'ancrer quand
* la liste est vide ; l'erreur s'affiche donc sous le select « Type de
* règlement », binde cote front). Le 409 sur DELETE du dernier RIB en LCR est
* porte par le ProviderRibProcessor (ERP-135).
*
* Ces champs vivant dans le groupe d'ecriture comptable (absent du POST, qui
* n'expose que provider:write:main), la contrainte ne mord en pratique que sur
* le PATCH de l'onglet Comptabilite.
*/
#[Assert\Callback]
public function validatePaymentTypeConsistency(ExecutionContextInterface $context): void
{
$paymentCode = $this->paymentType?->getCode();
if (self::PAYMENT_TYPE_VIREMENT === $paymentCode && null === $this->bank) {
$context->buildViolation('La banque est obligatoire pour le type de règlement Virement.')
->atPath('bank')
->addViolation()
;
}
if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) {
$context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.')
->atPath('paymentType')
->addViolation()
;
}
}
public function getId(): ?int
{
return $this->id;
}
public function getCompanyName(): ?string
{
return $this->companyName;
}
public function setCompanyName(string $companyName): static
{
$this->companyName = $companyName;
return $this;
}
/** @return Collection<int, CategoryInterface> */
public function getCategories(): Collection
{
return $this->categories;
}
public function addCategory(CategoryInterface $category): static
{
if (!$this->categories->contains($category)) {
$this->categories->add($category);
}
return $this;
}
public function removeCategory(CategoryInterface $category): static
{
$this->categories->removeElement($category);
return $this;
}
/** @return Collection<int, SiteInterface> */
public function getSites(): Collection
{
return $this->sites;
}
public function addSite(SiteInterface $site): static
{
if (!$this->sites->contains($site)) {
$this->sites->add($site);
}
return $this;
}
public function removeSite(SiteInterface $site): static
{
$this->sites->removeElement($site);
return $this;
}
public function getSiren(): ?string
{
return $this->siren;
}
public function setSiren(?string $siren): static
{
$this->siren = $siren;
return $this;
}
public function getAccountNumber(): ?string
{
return $this->accountNumber;
}
public function setAccountNumber(?string $accountNumber): static
{
$this->accountNumber = $accountNumber;
return $this;
}
public function getTvaMode(): ?TvaMode
{
return $this->tvaMode;
}
public function setTvaMode(?TvaMode $tvaMode): static
{
$this->tvaMode = $tvaMode;
return $this;
}
public function getNTva(): ?string
{
return $this->nTva;
}
public function setNTva(?string $nTva): static
{
$this->nTva = $nTva;
return $this;
}
public function getPaymentDelay(): ?PaymentDelay
{
return $this->paymentDelay;
}
public function setPaymentDelay(?PaymentDelay $paymentDelay): static
{
$this->paymentDelay = $paymentDelay;
return $this;
}
public function getPaymentType(): ?PaymentType
{
return $this->paymentType;
}
public function setPaymentType(?PaymentType $paymentType): static
{
$this->paymentType = $paymentType;
return $this;
}
public function getBank(): ?Bank
{
return $this->bank;
}
public function setBank(?Bank $bank): static
{
$this->bank = $bank;
return $this;
}
/** @return Collection<int, ProviderContact> */
#[Groups(['provider:item:read'])]
public function getContacts(): Collection
{
return $this->contacts;
}
public function addContact(ProviderContact $contact): static
{
if (!$this->contacts->contains($contact)) {
$this->contacts->add($contact);
$contact->setProvider($this);
}
return $this;
}
public function removeContact(ProviderContact $contact): static
{
if ($this->contacts->removeElement($contact) && $contact->getProvider() === $this) {
$contact->setProvider(null);
}
return $this;
}
/** @return Collection<int, ProviderAddress> */
#[Groups(['provider:item:read'])]
public function getAddresses(): Collection
{
return $this->addresses;
}
public function addAddress(ProviderAddress $address): static
{
if (!$this->addresses->contains($address)) {
$this->addresses->add($address);
$address->setProvider($this);
}
return $this;
}
public function removeAddress(ProviderAddress $address): static
{
if ($this->addresses->removeElement($address) && $address->getProvider() === $this) {
$address->setProvider(null);
}
return $this;
}
// Embed gate sur le groupe COMPTABLE (et non provider:item:read comme contacts/
// adresses) : provider:read:accounting n'est ajoute au contexte que si l'user a
// accounting.view (ProviderProvider / ReadGroupContextBuilder, ERP-134). Resultat :
// la cle `ribs` est TOTALEMENT ABSENTE du detail pour un user sans accounting.view
// (ex. Commerciale), au meme titre que les scalaires comptables — evite la fuite
// IBAN/BIC (piege n°4 M1).
/** @return Collection<int, ProviderRib> */
#[Groups(['provider:read:accounting'])]
public function getRibs(): Collection
{
return $this->ribs;
}
public function addRib(ProviderRib $rib): static
{
if (!$this->ribs->contains($rib)) {
$this->ribs->add($rib);
$rib->setProvider($this);
}
return $this;
}
public function removeRib(ProviderRib $rib): static
{
if ($this->ribs->removeElement($rib) && $rib->getProvider() === $this) {
$rib->setProvider(null);
}
return $this;
}
// Groupe de lecture + nom serialise explicite : sans SerializedName, Symfony
// exposerait la cle "archived" (strip du prefixe "is" sur les getters) et
// droppait silencieusement la cle du JSON (piege n°3 du M1).
#[Groups(['provider: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;
}
}
@@ -1,370 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderAddressProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
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\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Adresse d'un prestataire (1:n) onglet Adresse. Version SIMPLIFIEE de
* SupplierAddress : PAS de address_type (PROSPECT/DEPART/RENDU), PAS de bennes,
* PAS de triage_provider (champs specifiques fournisseur). Champs : country /
* postal_code / city / street / street_complement + M2M sites / contacts /
* categories.
*
* Relations M2M :
* - sites : SiteInterface (module Sites) via resolve_target_entities au moins
* un site obligatoire (RG-3.05, Assert\Count). Site n'a pas de `code`.
* - contacts : ProviderContact (meme module).
* - categories : CategoryInterface (module Catalog) via resolve_target_entities
* type PRESTATAIRE attendu (RG-3.09, Assert\Callback validateCategoryType).
*
* Embarquee sous `provider.addresses` au detail (groupe provider:item:read,
* maillon (a)).
*
* Sous-ressource API (ERP-135, spec § 4.5) :
* - POST /api/providers/{providerId}/addresses : creation rattachee au prestataire
* parent (Link toProperty 'provider'), security technique.providers.manage.
* - PATCH / DELETE /api/provider_addresses/{id} : security technique.providers.manage.
* - GET /api/provider_addresses/{id} : lecture unitaire (security view) la lecture
* courante reste via le parent. Pas de GET collection autonome.
* Tout passe par le ProviderAddressProcessor (rattachement parent + cloisonnement
* d'ecriture des sites, § 2.13). Les regles RG-3.05/3.06/3.09 sont portees par les
* contraintes de l'entite (jouees avant le processor).
*
* Audite (#[Auditable]) + Timestampable / Blamable.
*/
#[ApiResource(
operations: [
new Get(
security: "is_granted('technique.providers.view')",
// site:read + category:read : embarquent les Site / Category lies
// (maillon (c)) plutot que des IRI nus dans le retour.
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
provider: ProviderSubResourceItemProvider::class,
),
new Post(
uriTemplate: '/providers/{providerId}/addresses',
uriVariables: [
'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
],
// read:false : pas de stade lecture du parent. Le Link toProperty
// resoudrait l'enfant (SELECT ProviderAddress ... WHERE provider = :id)
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
// manuellement par ProviderAddressProcessor::linkParent (404 si absent).
read: false,
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
denormalizationContext: ['groups' => ['provider:write:addresses']],
processor: ProviderAddressProcessor::class,
),
new Patch(
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
denormalizationContext: ['groups' => ['provider:write:addresses']],
provider: ProviderSubResourceItemProvider::class,
processor: ProviderAddressProcessor::class,
),
new Delete(
security: "is_granted('technique.providers.manage')",
provider: ProviderSubResourceItemProvider::class,
processor: ProviderAddressProcessor::class,
),
],
)]
#[ORM\Entity]
#[ORM\Table(name: 'provider_address')]
#[ORM\Index(name: 'idx_provider_address_provider', columns: ['provider_id'])]
#[Auditable]
class ProviderAddress implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
{
use TimestampableBlamableTrait;
/**
* RG-3.09 : seules les categories PORTANT ce type sont autorisees sur une
* adresse prestataire. S'appuie sur CategoryInterface::getCategoryTypeCodes()
* (pas d'import du module Catalog regle ABSOLUE n°1).
*/
private const string REQUIRED_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['provider:item:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Provider::class, inversedBy: 'addresses')]
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Provider $provider = 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(['provider:item:read', 'provider:write:addresses'])]
private string $country = 'France';
// RG-3.06 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur).
// Le Regex borne deja la longueur (<= 5) : pas de Length redondant (whitelist
// ERP-107).
#[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(['provider:item:read', 'provider:write:addresses'])]
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(['provider:item:read', 'provider:write:addresses'])]
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(['provider:item:read', 'provider:write:addresses'])]
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(['provider:item:read', 'provider:write:addresses'])]
private ?string $streetComplement = null;
// Ordre d'affichage de l'adresse (gere serveur, non expose au M3).
#[ORM\Column(options: ['default' => 0])]
private int $position = 0;
// RG-3.05 : au moins un site rattache a chaque adresse.
/** @var Collection<int, SiteInterface> */
#[ORM\ManyToMany(targetEntity: SiteInterface::class)]
#[ORM\JoinTable(name: 'provider_address_site')]
#[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')]
#[Groups(['provider:item:read', 'provider:write:addresses'])]
private Collection $sites;
/** @var Collection<int, ProviderContact> */
#[ORM\ManyToMany(targetEntity: ProviderContact::class)]
#[ORM\JoinTable(name: 'provider_address_contact')]
#[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'provider_contact_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[Groups(['provider:item:read', 'provider:write:addresses'])]
private Collection $contacts;
// RG-3.09 : au moins une categorie de type PRESTATAIRE par adresse (le type est
// controle par validateCategoryType ; le minimum par Assert\Count, miroir sites).
/** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'provider_address_category')]
#[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
#[Groups(['provider:item:read', 'provider:write:addresses'])]
private Collection $categories;
public function __construct()
{
$this->sites = new ArrayCollection();
$this->contacts = new ArrayCollection();
$this->categories = new ArrayCollection();
}
/**
* RG-3.09 : toute categorie posee sur une adresse prestataire doit etre de
* type PRESTATAIRE -> sinon 422 avec violation sur le champ `categories`
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
* CategoryInterface::getCategoryTypeCodes() (multi-type la categorie est
* acceptee des qu'elle PORTE le type PRESTATAIRE ; pas d'import du module
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
*/
#[Assert\Callback]
public function validateCategoryType(ExecutionContextInterface $context): void
{
foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
$context->buildViolation('Type de catégorie non autorisé (PRESTATAIRE attendu).')
->atPath('categories')
->addViolation()
;
return;
}
}
}
public function getId(): ?int
{
return $this->id;
}
public function getProvider(): ?Provider
{
return $this->provider;
}
public function setProvider(?Provider $provider): static
{
$this->provider = $provider;
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;
}
/** @return Collection<int, SiteInterface> */
public function getSites(): Collection
{
return $this->sites;
}
public function addSite(SiteInterface $site): static
{
if (!$this->sites->contains($site)) {
$this->sites->add($site);
}
return $this;
}
public function removeSite(SiteInterface $site): static
{
$this->sites->removeElement($site);
return $this;
}
/** @return Collection<int, ProviderContact> */
public function getContacts(): Collection
{
return $this->contacts;
}
public function addContact(ProviderContact $contact): static
{
if (!$this->contacts->contains($contact)) {
$this->contacts->add($contact);
}
return $this;
}
public function removeContact(ProviderContact $contact): static
{
$this->contacts->removeElement($contact);
return $this;
}
/** @return Collection<int, CategoryInterface> */
public function getCategories(): Collection
{
return $this->categories;
}
public function addCategory(CategoryInterface $category): static
{
if (!$this->categories->contains($category)) {
$this->categories->add($category);
}
return $this;
}
public function removeCategory(CategoryInterface $category): static
{
$this->categories->removeElement($category);
return $this;
}
}
@@ -1,241 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderContactProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Contact d'un prestataire (1:n) — onglet Contacts. Un bloc est valide des qu'au
* moins un champ est rempli (RG-3.04) : garantie portee par un CHECK BDD
* (chk_provider_contact_name) + le ProviderContactProcessor (ERP-135) ; l'entite
* reste permissive (tous les champs nullable).
*
* Embarque sous `provider.contacts` au detail (groupe provider:item:read,
* maillon (a) du contrat de serialisation). Maximum 2 telephones
* (phonePrimary + phoneSecondary).
*
* Sous-ressource API (ERP-135, spec § 4.5) :
* - POST /api/providers/{providerId}/contacts : creation rattachee au prestataire
* parent (Link toProperty 'provider'), security technique.providers.manage.
* - PATCH / DELETE /api/provider_contacts/{id} : security technique.providers.manage.
* Le DELETE est physique et libre (pas de garde « dernier contact » au M3
* RG-3.12 front-driven, la collection peut rester vide cote back).
* - GET /api/provider_contacts/{id} : lecture unitaire (security view) la lecture
* courante reste via le parent (le prestataire embarque ses contacts). Pas de GET
* collection autonome.
* Tout passe par le ProviderContactProcessor (normalisation RG-3.11, RG-3.04).
*
* Audite (#[Auditable]) + Timestampable / Blamable (pattern Shared standard).
*/
#[ApiResource(
operations: [
new Get(
security: "is_granted('technique.providers.view')",
normalizationContext: ['groups' => ['provider:item:read']],
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
provider: ProviderSubResourceItemProvider::class,
),
new Post(
uriTemplate: '/providers/{providerId}/contacts',
uriVariables: [
'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
],
// read:false : pas de stade lecture du parent. Le Link toProperty
// resoudrait l'enfant (SELECT ProviderContact ... WHERE provider = :id)
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
// manuellement par ProviderContactProcessor::linkParent (404 si absent).
read: false,
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:item:read']],
denormalizationContext: ['groups' => ['provider:write:contacts']],
processor: ProviderContactProcessor::class,
),
new Patch(
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:item:read']],
denormalizationContext: ['groups' => ['provider:write:contacts']],
provider: ProviderSubResourceItemProvider::class,
processor: ProviderContactProcessor::class,
),
new Delete(
security: "is_granted('technique.providers.manage')",
provider: ProviderSubResourceItemProvider::class,
processor: ProviderContactProcessor::class,
),
],
)]
#[ORM\Entity]
#[ORM\Table(name: 'provider_contact')]
#[ORM\Index(name: 'idx_provider_contact_provider', columns: ['provider_id'])]
#[Auditable]
class ProviderContact implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['provider:item:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Provider::class, inversedBy: 'contacts')]
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Provider $provider = null;
// RG-3.04 : au moins un champ du contact renseigne (CHECK BDD + Processor). Les
// champs restent nullable au niveau ORM.
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $lastName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $jobTitle = null;
// Pas de validation de format telephone (saisie libre), mais une Assert\Length
// calee sur la colonne VARCHAR(20) evite l'erreur Postgres (500 non rattachee au
// champ) au profit d'une 422 propre (ERP-107).
#[ORM\Column(length: 20, nullable: true)]
#[Assert\Length(max: 20, maxMessage: 'Le téléphone ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $phonePrimary = null;
#[ORM\Column(length: 20, nullable: true)]
#[Assert\Length(max: 20, maxMessage: 'Le téléphone secondaire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $phoneSecondary = null;
#[ORM\Column(length: 180, nullable: true)]
#[Assert\Email(message: 'L\'adresse email n\'est pas valide.')]
#[Assert\Length(max: 180, maxMessage: 'L\'email ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $email = null;
// Ordre d'affichage du contact (gere serveur, non expose au M3).
#[ORM\Column(options: ['default' => 0])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getProvider(): ?Provider
{
return $this->provider;
}
public function setProvider(?Provider $provider): static
{
$this->provider = $provider;
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;
}
}
@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Entity;
/**
* Contrat des sous-ressources d'un prestataire (Contact, Adresse, RIB) : chacune
* appartient a un Provider parent. Permet au provider decore
* ProviderSubResourceItemProvider d'appliquer le cloisonnement par site du parent
* (§ 2.13 / RG-3.17) de maniere uniforme, sans connaitre le type concret.
*/
interface ProviderOwnedInterface
{
public function getProvider(): ?Provider;
}
@@ -1,197 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderRibProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Coordonnees bancaires d'un prestataire (1:n) onglet Comptabilite. Au moins un
* RIB est obligatoire si le type de reglement est LCR (RG-3.08, verifie au
* ProviderRibProcessor : refus du DELETE du dernier RIB sous LCR ERP-135).
*
* Embarque sous `provider.ribs` UNIQUEMENT si l'user a accounting.view : le
* read-group est `provider:read:accounting`, retire du contexte par le
* ProviderProvider sinon (gating par omission de cle evite la fuite IBAN/BIC,
* piege n°4 du M1). Aucun #[AuditIgnore] sur iban/bic : l'audit etant admin-only,
* la tracabilite RIB est conservee (decision M1 reportee, § 2.7).
*
* Sous-ressource API (ERP-135, spec § 4.5) gating comptable renforce :
* - POST /api/providers/{providerId}/ribs : creation rattachee au prestataire
* parent (Link toProperty 'provider'), security technique.providers.accounting.manage.
* - PATCH / DELETE /api/provider_ribs/{id} : security technique.providers.accounting.manage.
* Le DELETE refuse la suppression du dernier RIB sous LCR (RG-3.08, 409).
* - GET /api/provider_ribs/{id} : lecture unitaire, security
* technique.providers.accounting.view (donnees bancaires sensibles). Pas de GET
* collection autonome.
* Tout passe par le ProviderRibProcessor (RG-3.08 sur DELETE).
*
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony (pas de controle
* banque reelle), avec controle croise pays BIC/IBAN (ibanPropertyPath). Audite
* (#[Auditable]) + Timestampable / Blamable.
*/
#[ApiResource(
operations: [
new Get(
security: "is_granted('technique.providers.accounting.view')",
normalizationContext: ['groups' => ['provider:read:accounting']],
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
provider: ProviderSubResourceItemProvider::class,
),
new Post(
uriTemplate: '/providers/{providerId}/ribs',
uriVariables: [
'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
],
// read:false : pas de stade lecture du parent. Le Link toProperty
// resoudrait l'enfant (SELECT ProviderRib ... WHERE provider = :id) et
// casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
// manuellement par ProviderRibProcessor::linkParent (404 si absent).
read: false,
security: "is_granted('technique.providers.accounting.manage')",
normalizationContext: ['groups' => ['provider:read:accounting']],
denormalizationContext: ['groups' => ['provider:write:accounting']],
processor: ProviderRibProcessor::class,
),
new Patch(
security: "is_granted('technique.providers.accounting.manage')",
normalizationContext: ['groups' => ['provider:read:accounting']],
denormalizationContext: ['groups' => ['provider:write:accounting']],
provider: ProviderSubResourceItemProvider::class,
processor: ProviderRibProcessor::class,
),
new Delete(
security: "is_granted('technique.providers.accounting.manage')",
provider: ProviderSubResourceItemProvider::class,
processor: ProviderRibProcessor::class,
),
],
)]
#[ORM\Entity]
#[ORM\Table(name: 'provider_rib')]
#[ORM\Index(name: 'idx_provider_rib_provider', columns: ['provider_id'])]
#[Auditable]
class ProviderRib implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['provider:read:accounting'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Provider::class, inversedBy: 'ribs')]
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Provider $provider = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'Le libellé du RIB est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'Le libellé ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $label = null;
// Bic/Iban bornent deja le format (et donc la longueur) : pas de Length redondant
// calee sur la colonne (auto-exempte du miroir ERP-107). ibanPropertyPath :
// controle croise — le pays du BIC (positions 5-6) doit correspondre au pays de
// l'IBAN (positions 1-2). Violation portee sur `bic`.
#[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')]
#[Assert\Bic(
message: 'Le BIC n\'est pas valide.',
ibanPropertyPath: 'iban',
ibanMessage: 'Le BIC ne correspond pas au pays de l\'IBAN.',
)]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $bic = null;
#[ORM\Column(length: 34)]
#[Assert\NotBlank(message: 'L\'IBAN est obligatoire.', normalizer: 'trim')]
#[Assert\Iban(message: 'L\'IBAN n\'est pas valide.')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $iban = null;
// Ordre d'affichage du RIB (gere serveur, non expose au M3).
#[ORM\Column(options: ['default' => 0])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getProvider(): ?Provider
{
return $this->provider;
}
public function setProvider(?Provider $provider): static
{
$this->provider = $provider;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getBic(): ?string
{
return $this->bic;
}
public function setBic(string $bic): static
{
$this->bic = $bic;
return $this;
}
public function getIban(): ?string
{
return $this->iban;
}
public function setIban(string $iban): static
{
$this->iban = $iban;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -1,98 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Repository;
use App\Module\Technique\Domain\Entity\Provider;
use Doctrine\ORM\QueryBuilder;
interface ProviderRepositoryInterface
{
public function findById(int $id): ?Provider;
public function save(Provider $provider): void;
/**
* Restreint un QueryBuilder de liste aux prestataires rattaches au site donne
* (relation DIRECTE provider.sites). Sert le cloisonnement par site pilote par
* l'utilisateur (RG-3.17, § 2.13) : le ProviderProvider resout le site courant
* (CurrentSiteProvider) puis appelle cette methode quand l'user n'a pas
* `sites.bypass_scope`. Decouple ainsi la DECISION (Provider, qui connait
* l'user) du DQL (repository, qui ne connait que l'id de site).
*
* Sous-requete IN (et non JOIN sur la M2M) pour ne pas perturber le
* DISTINCT / ORDER BY / pagination du QueryBuilder de selection meme parti
* pris que les filtres ?categoryCode / ?siteId. Applique AVANT la pagination
* (le COUNT du Paginator reflete alors le perimetre de l'user).
*/
public function applySiteScope(QueryBuilder $qb, int $siteId): void;
/**
* Construit un QueryBuilder de liste pour le repertoire prestataires.
* - Exclut toujours les prestataires soft-deletes (deleted_at IS NOT NULL, RG-3.16).
* - Archivage (RG-3.16) :
* - $archivedOnly = true -> uniquement les archives (is_archived = true) ;
* - sinon $includeArchived = true -> actifs + archives (echappatoire) ;
* - sinon (defaut) -> uniquement les actifs (is_archived = false).
* $archivedOnly a la priorite sur $includeArchived.
* - Tri par defaut : companyName ASC (RG-3.16).
* - $search : recherche fuzzy insensible a la casse sur companyName + les
* contacts lies (firstName / lastName / email) via sous-requete.
* Metacaracteres LIKE echappes. Ignore si null/vide.
* - $categoryCodes : restreint aux prestataires possedant au moins une
* categorie dont le code est dans la liste (OR). Liste vide = pas de filtre.
* - $siteIds : restreint aux prestataires rattaches a l'un des sites donnes
* (OR RG-3.03, relation DIRECTE provider.sites). Liste vide = pas de filtre.
*
* Filtrage centralise ICI (et non dans le provider/controller) pour que la
* liste paginee et l'export partagent strictement la meme logique de selection
* (miroir M2).
*
* Contrat = SELECTION uniquement (filtres + tri). Aucun fetch-join to-many :
* l'hydratation des collections affichees est deleguee a
* {@see self::hydrateListCollections()} pour ne pas imposer le cout d'un
* produit cartesien aux chemins non pagines (§ 2.12, cf. M1/ERP-100, M2).
*
* NB : le cloisonnement par site pilote par l'utilisateur (RG-3.17, § 2.13) est
* applique en AMONT par le ProviderProvider (ERP-134), pas par ce QueryBuilder
* (qui ne connait pas l'user courant).
*
* @param list<string> $categoryCodes
* @param list<int> $siteIds
*/
public function createListQueryBuilder(
bool $includeArchived = false,
?string $search = null,
array $categoryCodes = [],
array $siteIds = [],
bool $archivedOnly = false,
): QueryBuilder;
/**
* Hydrate en lot les collections affichees par le repertoire (categories puis
* sites relation DIRECTE provider.sites, RG-3.03) sur un jeu de prestataires
* DEJA charges, via l'identity map Doctrine (memes instances). A appeler apres
* une selection bornee (page courante ou jeu d'export) pour eviter le N+1 a la
* serialisation, sans imposer de fetch-join au QueryBuilder de selection
* (anti N+1, § 2.12).
*
* Charge les categories et les sites en DEUX requetes distinctes (et non un
* double fetch-join) pour ne pas multiplier categories x sites en un seul
* produit cartesien.
*
* @param list<Provider> $providers
*/
public function hydrateListCollections(array $providers): void;
/**
* Hydrate en lot la collection `contacts` sur un jeu de prestataires DEJA
* charges (memes instances via l'identity map). Reservee aux chemins qui ont
* besoin du contact principal (export) : la LISTE paginee n'embarque pas les
* contacts (§ 2.12), d'ou une methode dediee plutot qu'une passe supplementaire
* dans {@see self::hydrateListCollections()}.
*
* @param list<Provider> $providers
*/
public function hydrateContacts(array $providers): void;
}
@@ -1,76 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\Serializer;
use ApiPlatform\State\SerializerContextBuilderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\HttpFoundation\Request;
/**
* Decore le context builder de serialisation d'API Platform pour ajouter
* DYNAMIQUEMENT le groupe de lecture `provider:read:accounting` sur les
* ressources Provider, uniquement si l'utilisateur courant a la permission
* `technique.providers.accounting.view` (cf. spec-back M3 § 2.9 / § 4.1 /
* § 4.2). Jumeau de SupplierReadGroupContextBuilder (M2).
*
* Pourquoi un context builder et pas le Provider : un Provider retourne des
* donnees mais ne peut pas influencer les groupes de serialisation. Le contexte
* de normalisation est construit ici, en amont du serializer c'est le point
* d'extension idiomatique d'API Platform pour conditionner un groupe selon
* l'utilisateur. Realise l'intention « gating du groupe accounting » de la spec
* (le groupe n'est jamais pose par defaut sur l'operation : il est AJOUTE ici si
* la permission est presente resultat identique au « retrait » decrit en spec).
*
* S'applique aux operations de LECTURE (normalization) sur Provider : liste ET
* detail. Sans la permission, les champs comptables (siren, accountNumber,
* tvaMode, nTva, paymentDelay, paymentType, bank) ET les RIB embarques (groupe
* provider:read:accounting porte par getRibs()) ne sont jamais serialises la
* cle est totalement absente du JSON (gating par omission, parade bug #4 M1).
*
* Priorite de decoration -20 : on s'empile APRES les decorateurs Commercial
* (ClientReadGroupContextBuilder priorite 0, SupplierReadGroupContextBuilder
* priorite -10) sur le meme service `api_platform.serializer.context_builder`.
* Chaque decorateur passe la main pour toute ressource autre que la sienne :
* l'ordre de chainage n'a donc aucun effet fonctionnel, la priorite explicite ne
* sert qu'a lever l'ambiguite de plusieurs decorateurs sur un meme service.
*/
#[AsDecorator(decorates: 'api_platform.serializer.context_builder', priority: -20)]
final readonly class ProviderReadGroupContextBuilder implements SerializerContextBuilderInterface
{
public function __construct(
#[AutowireDecorated]
private SerializerContextBuilderInterface $decorated,
private Security $security,
) {}
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
// Uniquement en lecture, sur la ressource Provider, avec la permission.
if (!$normalization) {
return $context;
}
if (Provider::class !== ($context['resource_class'] ?? null)) {
return $context;
}
if (!$this->security->isGranted('technique.providers.accounting.view')) {
return $context;
}
$groups = $context['groups'] ?? [];
if (!in_array('provider:read:accounting', $groups, true)) {
$groups[] = 'provider:read:accounting';
}
$context['groups'] = $groups;
return $context;
}
}
@@ -1,220 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Core\Domain\Entity\User;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderAddress;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use App\Shared\Domain\Contract\SiteInterface;
use Doctrine\ORM\EntityManagerInterface;
use JsonException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture de la sous-ressource Adresse d'un prestataire (M3,
* spec-back § 4.5). Jumeau du SupplierAddressProcessor (M2), recentre sur le
* perimetre ERP-135, AVEC une garde supplementaire propre au M3 : le
* cloisonnement d'ECRITURE des sites de l'adresse (§ 2.13).
*
* Sequence :
* - POST / PATCH : rattachement au prestataire parent puis cloisonnement des
* sites de l'adresse (RG-3.05 / § 2.13). Les regles de l'onglet Adresse sont
* garanties en amont par des contraintes sur l'entite, jouees par API Platform
* avant ce processor : RG-3.06 (code postal, Assert\Regex), RG-3.05 (>= 1 site,
* Assert\Count), RG-3.09 (categorie de type PRESTATAIRE, Assert\Callback
* ProviderAddress::validateCategoryType).
* - DELETE : aucune regle metier specifique (suppression physique directe).
*
* La security de l'operation (technique.providers.manage) est appliquee par API
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut.
*
* @implements ProcessorInterface<ProviderAddress, null|ProviderAddress>
*/
final class ProviderAddressProcessor implements ProcessorInterface
{
private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope';
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof ProviderAddress) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
$this->guardSiteScope($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache l'adresse au prestataire parent de la sous-ressource POST
* (/providers/{providerId}/addresses) : la relation n'est pas peuplee
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
*/
private function linkParent(ProviderAddress $address, array $uriVariables): void
{
if (null !== $address->getProvider()) {
return;
}
$providerId = $uriVariables['providerId'] ?? null;
if (null === $providerId) {
return;
}
$provider = $providerId instanceof Provider
? $providerId
: $this->em->getRepository(Provider::class)->find($providerId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
// contrainte provider_id NOT NULL).
if (!$provider instanceof Provider) {
throw new NotFoundHttpException('Prestataire introuvable.');
}
// Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
// sous-ressource sur un prestataire hors du perimetre de l'user -> 404
// (anti-enumeration). Distinct du guardSiteScope ci-dessous, qui cloisonne
// les sites ATTACHES a l'adresse (et non l'acces au prestataire parent).
$this->scopeChecker->assertInScope($provider);
$address->setProvider($provider);
}
/**
* RG-3.05 / § 2.13 (cloisonnement d'ECRITURE decision Matthieu 11/06) : un
* user SANS `sites.bypass_scope` ne peut attacher a CHAQUE adresse que des
* sites figurant dans ses propres `user_site`. Tout site hors perimetre -> 422
* sur `sites` (propertyPath consommable inline, convention ERP-101). Un user
* `bypass_scope` (Admin) peut attacher n'importe quel site. Miroir de
* ProviderProcessor::guardSiteScope, applique ici a la sous-ressource adresse.
*
* Ne joue que si `sites` est effectivement soumis : POST (entite non geree,
* sites obligatoires RG-3.05) ou PATCH portant la cle `sites`. Un PATCH qui ne
* touche pas aux sites n'est pas re-valide (les sites ont ete cloisonnes a leur
* pose). La validation porte sur l'ETAT RESULTANT (address.getSites()).
*/
private function guardSiteScope(ProviderAddress $address): void
{
if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) {
return;
}
// sites non soumis sur un PATCH : rien a cloisonner.
if ($this->em->contains($address) && !in_array('sites', $this->payloadKeys(), true)) {
return;
}
$allowedSiteIds = $this->currentUserSiteIds();
foreach ($address->getSites() as $site) {
if (!$site instanceof SiteInterface) {
continue;
}
if (!in_array($site->getId(), $allowedSiteIds, true)) {
$this->throwSitesViolation($address);
}
}
}
/**
* Identifiants des sites rattaches a l'utilisateur courant (`user_site`).
* Vide si pas d'user authentifie (cas defensif : la security d'operation
* garantit deja l'authentification).
*
* @return list<int>
*/
private function currentUserSiteIds(): array
{
$user = $this->security->getUser();
if (!$user instanceof User) {
return [];
}
$ids = [];
foreach ($user->getSites() as $site) {
if ($site instanceof SiteInterface && null !== $site->getId()) {
$ids[] = $site->getId();
}
}
return $ids;
}
/**
* Cles de premier niveau effectivement envoyees par le client (payload JSON
* brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies.
* Corps vide ou JSON invalide -> aucune cle.
*
* @return list<string>
*/
private function payloadKeys(): array
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return [];
}
$content = $request->getContent();
if ('' === $content) {
return [];
}
try {
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return [];
}
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
}
/**
* Leve une 422 portant une violation unique sur `sites` meme rendu Hydra que
* les contraintes Symfony, consommable inline par extractApiViolations (ERP-101).
*
* @return never
*/
private function throwSitesViolation(ProviderAddress $address): void
{
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Vous ne pouvez rattacher que des sites auxquels vous avez accès.',
null,
[],
$address,
'sites',
null,
));
throw new ValidationException($violations);
}
}
@@ -1,144 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderContact;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture de la sous-ressource Contact d'un prestataire (M3,
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
* perimetre ERP-135.
*
* Sequence :
* - POST / PATCH : rattachement au prestataire parent, normalisation serveur
* (RG-3.11 : prenom/nom Title Case, telephones reduits aux chiffres, email
* lowercase) via le ProviderFieldNormalizer partage, puis validation RG-3.04
* (au moins un champ parmi prenom / nom / telephone principal / email) avant
* persistance.
* - DELETE : aucune garde « dernier contact » au M3 la collection peut rester
* vide cote back (RG-3.12 front-driven, spec § 4.5). Suppression physique directe.
*
* La security de l'operation (technique.providers.manage) est appliquee par API
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut
* (Assert\Email, Assert\Length...).
*
* @implements ProcessorInterface<ProviderContact, null|ProviderContact>
*/
final class ProviderContactProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly ProviderFieldNormalizer $normalizer,
private readonly EntityManagerInterface $em,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof ProviderContact) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
$this->normalize($data);
$this->validateName($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache le contact au prestataire parent de la sous-ressource POST
* (/providers/{providerId}/contacts). La relation n'est pas peuplee
* automatiquement par le Link sur une operation d'ecriture : on resout le
* parent depuis l'uri variable. Sur PATCH (entite existante), le prestataire
* est deja present -> no-op.
*/
private function linkParent(ProviderContact $contact, array $uriVariables): void
{
if (null !== $contact->getProvider()) {
return;
}
$providerId = $uriVariables['providerId'] ?? null;
if (null === $providerId) {
return;
}
$provider = $providerId instanceof Provider
? $providerId
: $this->em->getRepository(Provider::class)->find($providerId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
// contrainte provider_id NOT NULL).
if (!$provider instanceof Provider) {
throw new NotFoundHttpException('Prestataire introuvable.');
}
// Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
// sous-ressource sur un prestataire hors du perimetre de l'user -> 404
// (anti-enumeration, coherent avec le detail Provider garde en 404).
$this->scopeChecker->assertInScope($provider);
$contact->setProvider($provider);
}
/**
* Normalisation serveur (RG-3.11). Toutes les methodes du normalizer sont
* null-safe : une chaine vide apres trim devient null.
*/
private function normalize(ProviderContact $contact): void
{
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
$contact->setJobTitle($this->normalizer->normalizeText($contact->getJobTitle()));
$contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary()));
$contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary()));
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
}
/**
* RG-3.04 : un bloc Contact exige au moins le prenom OU le nom (aligne sur le
* M1/M2 un contact se materialise par son nom ; fonction / telephone / email
* seuls ne suffisent pas). Double garde avec le CHECK BDD chk_provider_contact_name
* leve une 422 propre rattachee au champ `firstName` plutot qu'une 500 SQL.
* Joue apres normalisation (les chaines vides sont deja ramenees a null).
*/
private function validateName(ProviderContact $contact): void
{
if (null === $contact->getFirstName() && null === $contact->getLastName()) {
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Le prénom ou le nom du contact est obligatoire.',
null,
[],
$contact,
'firstName',
null,
));
throw new ValidationException($violations);
}
}
}
@@ -1,590 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Core\Domain\Entity\User;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Application\Validator\ProviderAccountingCompletenessValidator;
use App\Module\Technique\Domain\Entity\Provider;
use App\Shared\Domain\Contract\SiteInterface;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\PersistentCollection;
use JsonException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture du repertoire prestataires (M3). Cf. spec-back M3 § 4.3 /
* § 4.4 + RG-3.10 / RG-3.13 / RG-3.14 / RG-3.15 / RG-3.17. Jumeau du
* SupplierProcessor (M2), avec deux differences structurantes (§ 3.1) :
* - PAS d'onglet Information (aucun champ description / competitors / ...) ni de
* validation de completude comptable -> le prestataire est minimal ;
* - le formulaire principal porte `sites` (M2M provider_site, RG-3.03), soumis au
* CLOISONNEMENT D'ECRITURE par site (RG-3.17, § 2.13) : un user sans
* `sites.bypass_scope` ne peut attacher que les sites de ses `user_site`.
*
* Sequence (POST / PATCH) :
* 1. Autorisation additionnelle par groupe d'onglet (mode strict RG-3.15). La
* security d'operation du PATCH est elargie a `manage` OU `accounting.manage`
* pour laisser entrer le role Compta ; ce processor re-gate alors finement :
* - champ comptable modifie dans le payload -> exige accounting.manage (403) ;
* - champ main (companyName / categories / sites) modifie -> exige manage
* (guardManage, 403) : empeche Compta d'editer un autre onglet ;
* - champ isArchived dans le payload -> exige archive (RG-3.13, 403) et
* interdit toute autre modification dans la meme requete (RG-3.13, 422).
* 2. Cloisonnement d'ECRITURE des sites (RG-3.17 / RG-3.03) : tout site attache
* hors des `user_site` de l'appelant non-bypass -> 422 sur `sites`.
* 3. Normalisation serveur (RG-3.11) via ProviderFieldNormalizer (companyName).
* 4. Pose / retrait de archivedAt (RG-3.13 true=now, RG-3.14 false=null).
* 5. Persistance via le persist_processor Doctrine, avec traduction des
* collisions d'unicite en 409 (RG-3.10 doublon de nom ; RG-3.14 conflit de
* restauration).
*
* Les RG inter-champs RG-3.07 (Virement -> banque), RG-3.08 (LCR -> >= 1 RIB) et
* RG-3.09 (categorie de type PRESTATAIRE) sont portees par des Assert\Callback +
* ->atPath() sur les entites Provider / ProviderAddress (jouees par API Platform
* AVANT ce processor), pour que chaque 422 porte un propertyPath consommable par
* extractApiViolations (mapping inline, pas un toast convention ERP-101). Le 409
* sur DELETE du dernier RIB en LCR (volet ecriture de RG-3.08) est porte par le
* ProviderRibProcessor (ERP-135).
*
* @implements ProcessorInterface<Provider, Provider>
*/
final class ProviderProcessor implements ProcessorInterface
{
/** Champs de l'onglet principal (groupe provider:write:main). */
private const array MAIN_FIELDS = [
'companyName', 'categories', 'sites',
];
/** Champs de l'onglet Comptabilite (groupe provider:write:accounting). */
private const array ACCOUNTING_FIELDS = [
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay',
'paymentType', 'bank',
];
/**
* Champs comptables obligatoires a la validation complete de l'onglet
* (spec-front M3 § Onglet Comptabilite miroir M1/M2). bank est exclu :
* conditionnel (RG-3.07).
*/
private const array ACCOUNTING_REQUIRED_FIELDS = [
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType',
];
/** Champ d'archivage (groupe provider:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived';
private const string PERM_MANAGE = 'technique.providers.manage';
private const string PERM_ACCOUNTING_MANAGE = 'technique.providers.accounting.manage';
private const string PERM_ARCHIVE = 'technique.providers.archive';
private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope';
/**
* Memoisation du dernier corps de requete decode, clos par le contenu brut
* (cf. SupplierProcessor) : payloadKeys() est appele plusieurs fois par requete,
* on evite de rejouer json_decode. Cle = contenu lui-meme, calcul pur -> aucune
* fuite entre requetes sur ce service partage.
*/
private ?string $decodedContent = null;
/** @var list<string> Cles de premier niveau correspondant au corps memoise. */
private array $decodedPayloadKeys = [];
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly ProviderFieldNormalizer $normalizer,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
private readonly ProviderAccountingCompletenessValidator $accountingValidator,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Provider) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// Reinitialisation de la memoisation du payload : le service est partage
// (stateful), on repart du corps de LA requete courante.
$this->decodedContent = null;
$this->decodedPayloadKeys = [];
$writableKeys = $this->writablePayloadKeys();
$isArchiveRequest = $this->guardArchive($data, $writableKeys);
$this->guardAccounting($data);
$this->guardSiteScope($data);
$this->normalize($data);
// guardManage apres normalize : la comparaison « change vs etat persiste »
// des champs texte (companyName) se fait sur des valeurs normalisees des
// deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data);
// Completude de l'onglet Comptabilite (apres normalize : les chaines vides
// sont deja ramenees a null). Joue uniquement sur une soumission d'onglet.
$this->validateAccountingCompleteness($data);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
// Le seul index unique partiel est uq_provider_company_name_active
// (LOWER(company_name) parmi non-archives/non-deletes — § 2.6).
if ($isArchiveRequest && false === $data->isArchived()) {
// RG-3.14 : restauration en conflit avec un homonyme actif.
throw new ConflictHttpException(
'Restauration impossible : un autre prestataire a pris le nom entre-temps.',
$e,
);
}
// RG-3.10 : doublon de nom de societe.
throw new ConflictHttpException(
sprintf('Un prestataire nommé "%s" existe déjà.', (string) $data->getCompanyName()),
$e,
);
}
}
/**
* RG-3.13 / RG-3.14 : si le payload bascule reellement isArchived, exige la
* permission archive (403), interdit toute autre modification (422) et
* pose/retire archivedAt. Retourne true si la requete est une requete
* d'archivage. Restreint a la mise a jour d'un prestataire existant ET au seul
* cas ou isArchived change vraiment (cf. SupplierProcessor).
*
* @param list<string> $writableKeys cles ecrivables du payload (hors @* et champs inconnus)
*/
private function guardArchive(Provider $data, array $writableKeys): bool
{
// POST / entite non geree : l'archivage est une action de mise a jour.
if (!$this->em->contains($data)) {
return false;
}
// isArchived inchange par rapport a l'etat persiste : pas une requete
// d'archivage (cas du PATCH representation complete).
if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) {
return false;
}
if (!$this->security->isGranted(self::PERM_ARCHIVE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
self::ARCHIVE_FIELD,
self::PERM_ARCHIVE,
));
}
// RG-3.13 : une requete d'archivage ne modifie aucun autre champ ecrivable.
if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) {
throw new UnprocessableEntityHttpException(
'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".',
);
}
// RG-3.13 (true -> now) / RG-3.14 (false -> null).
$data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);
return true;
}
/**
* RG-3.15 : la modification effective d'un champ comptable exige
* accounting.manage, sinon 403 sur l'ensemble du payload (mode strict, pas de
* filtrage silencieux). On ne gate que si un champ change reellement par
* rapport a l'etat persiste (POST/PATCH renvoyant des champs comptables
* inchanges ne declenche pas de 403 parasite). Le message precise le premier
* champ fautif.
*/
private function guardAccounting(Provider $data): void
{
$changed = $this->changedAccountingFields($data);
if ([] === $changed) {
return;
}
if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
$changed[0],
self::PERM_ACCOUNTING_MANAGE,
));
}
}
/**
* § 2.9 / RG-3.15 : la modification effective d'un champ « metier » (onglet
* principal : companyName / categories / sites) exige
* `technique.providers.manage`. Sans cette permission -> 403 sur l'ensemble du
* payload (mode strict, miroir de guardAccounting). C'est ce qui empeche le
* role Compta qui entre dans le PATCH via `accounting.manage` (security
* d'operation elargie) — d'editer autre chose que l'onglet Comptabilite.
*
* Ne s'applique qu'aux mises a jour (entite geree) : la creation (POST) est
* deja gardee par la security d'operation `manage`.
*/
private function guardManage(Provider $data): void
{
if (!$this->em->contains($data)) {
return;
}
$changed = $this->changedBusinessFields($data);
if ([] === $changed) {
return;
}
if (!$this->security->isGranted(self::PERM_MANAGE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
$changed[0],
self::PERM_MANAGE,
));
}
}
/**
* RG-3.17 / RG-3.03 (cloisonnement d'ECRITURE § 2.13) : un user SANS
* `sites.bypass_scope` ne peut attacher au prestataire que des sites figurant
* dans ses propres `user_site`. Tout site hors perimetre -> 422 sur `sites`
* (propertyPath consommable inline, convention ERP-101). Un user `bypass_scope`
* (Admin auto) peut attacher n'importe quel site.
*
* Interaction avec SiteCollectionScopedExtension (module Sites) : pour un user
* sans `sites.bypass_scope` NI `sites.read_ref`, la resolution de l'IRI de site
* hors perimetre echoue DEJA en amont (item Site « introuvable » -> 400
* anti-enumeration), avant ce processor. Cette garde reste donc l'enforcement
* AUTORITAIRE de RG-3.17 pour le cas particulier d'un user `sites.read_ref`
* (qui peut resoudre N'IMPORTE quel site comme referentiel transverse mais ne
* doit rattacher que ses propres sites), et une defense en profondeur sinon.
*
* Ne joue que si `sites` est effectivement soumis : POST (entite non geree,
* sites obligatoires RG-3.03) ou PATCH portant la cle `sites`. Un PATCH qui ne
* touche pas aux sites n'est pas re-valide (les sites ont ete cloisonnes a leur
* pose). La validation porte sur l'ETAT RESULTANT (data.getSites()).
*/
private function guardSiteScope(Provider $data): void
{
if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) {
return;
}
// sites non soumis sur un PATCH : rien a cloisonner.
if ($this->em->contains($data) && !in_array('sites', $this->payloadKeys(), true)) {
return;
}
$allowedSiteIds = $this->currentUserSiteIds();
foreach ($data->getSites() as $site) {
if (!$site instanceof SiteInterface) {
continue;
}
if (!in_array($site->getId(), $allowedSiteIds, true)) {
$this->throwSitesViolation($data);
}
}
}
/**
* Identifiants des sites rattaches a l'utilisateur courant (`user_site`).
* Vide si pas d'user authentifie (cas defensif : la security d'operation
* garantit deja l'authentification).
*
* @return list<int>
*/
private function currentUserSiteIds(): array
{
$user = $this->security->getUser();
if (!$user instanceof User) {
return [];
}
$ids = [];
foreach ($user->getSites() as $site) {
if ($site instanceof SiteInterface && null !== $site->getId()) {
$ids[] = $site->getId();
}
}
return $ids;
}
/**
* Champs « metier » (onglet principal : companyName / categories / sites) dont
* la valeur courante differe de l'etat persiste. Scalaires compares par valeur ;
* collections M2M (categories / sites) comparees par ensemble d'identifiants
* (cf. collectionChanged) la simple presence dans le payload ne suffit pas,
* sous peine de 403 parasite sur un PATCH representation complete.
*
* @return list<string>
*/
private function changedBusinessFields(Provider $data): array
{
$changed = [];
if ($this->fieldChanged($data, 'companyName', $data->getCompanyName())) {
$changed[] = 'companyName';
}
if ($this->collectionChanged($data, 'categories', $data->getCategories()->toArray())) {
$changed[] = 'categories';
}
if ($this->collectionChanged($data, 'sites', $data->getSites()->toArray())) {
$changed[] = 'sites';
}
return $changed;
}
/**
* Vrai si une collection M2M (`categories` ou `sites`) differe reellement de
* l'etat persiste. Ces collections ne sont pas tracees par
* getOriginalEntityData : on compare par identifiants (independamment de
* l'ordre) le snapshot de la PersistentCollection (etat charge) a l'etat
* courant (apres application du payload). Symetrique des scalaires : seul un
* changement effectif compte, pas la simple presence dans le payload.
*
* - POST / entite non geree : fournir la collection est un acte metier
* (branche defensive, guardManage ne s'execute que sur entite geree).
* - cle absente du payload (PATCH partiel) : aucun changement.
*
* @param array<int, object> $current
*/
private function collectionChanged(Provider $data, string $field, array $current): bool
{
if (!$this->em->contains($data)) {
return true;
}
if (!in_array($field, $this->payloadKeys(), true)) {
return false;
}
$collection = 'categories' === $field ? $data->getCategories() : $data->getSites();
// Hors PersistentCollection (cas limite hors flux PATCH reel) : faute d'etat
// persiste comparable, on se rabat sur la presence payload.
if (!$collection instanceof PersistentCollection) {
return true;
}
return $this->idSet($current) !== $this->idSet($collection->getSnapshot());
}
/**
* Ensemble trie des identifiants d'une liste d'entites pour une comparaison
* par valeur independante de l'ordre.
*
* @param array<int, object> $entities
*
* @return list<mixed>
*/
private function idSet(array $entities): array
{
$ids = array_map(
static fn (object $entity): mixed => method_exists($entity, 'getId')
? $entity->getId()
: spl_object_id($entity),
array_values($entities),
);
sort($ids);
return $ids;
}
/**
* Champs comptables dont la valeur courante differe de l'etat persiste. Les
* relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par
* identite d'objet : l'identity map Doctrine renvoie la meme instance tant que
* la reference est inchangee.
*
* @return list<string>
*/
private function changedAccountingFields(Provider $data): array
{
$changed = [];
foreach (self::ACCOUNTING_FIELDS as $field) {
$newValue = match ($field) {
'siren' => $data->getSiren(),
'accountNumber' => $data->getAccountNumber(),
'tvaMode' => $data->getTvaMode(),
'nTva' => $data->getNTva(),
'paymentDelay' => $data->getPaymentDelay(),
'paymentType' => $data->getPaymentType(),
'bank' => $data->getBank(),
};
if ($this->fieldChanged($data, $field, $newValue)) {
$changed[] = $field;
}
}
return $changed;
}
/**
* Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une
* entite non geree (creation/POST), l'etat persiste est vide : toute valeur
* non-null est alors un changement.
*/
private function fieldChanged(Provider $data, string $field, mixed $newValue): bool
{
$original = $this->originalData($data);
return $newValue !== ($original[$field] ?? null);
}
/**
* Snapshot des valeurs persistees de l'entite (telles que chargees, avant
* application du payload). Vide pour une entite non geree (POST).
*
* @return array<string, mixed>
*/
private function originalData(Provider $data): array
{
if (!$this->em->contains($data)) {
return [];
}
return $this->em->getUnitOfWork()->getOriginalEntityData($data);
}
/**
* Normalisation serveur du formulaire principal (RG-3.11). Seul companyName est
* porte par le Provider (les champs de contact sont normalises par le processor
* de sous-ressource ProviderContact, ticket dedie). Le setter non-nullable n'est
* touche que si une valeur est presente, pour ne jamais ecraser l'existant lors
* d'un PATCH partiel.
*/
private function normalize(Provider $data): void
{
if (null !== $data->getCompanyName()) {
$data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName()));
}
}
/**
* Cles ecrivables effectivement presentes dans le payload : on retire les cles
* JSON-LD (@id, @context...) et tout champ non rattache a un groupe d'ecriture
* connu. Base du 422 d'archivage (RG-3.13).
*
* @return list<string>
*/
private function writablePayloadKeys(): array
{
$writable = array_merge(
self::MAIN_FIELDS,
self::ACCOUNTING_FIELDS,
[self::ARCHIVE_FIELD],
);
return array_values(array_intersect($this->payloadKeys(), $writable));
}
/**
* Cles de premier niveau effectivement envoyees par le client (payload JSON
* brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies.
*
* @return list<string>
*/
/**
* Completude de l'onglet Comptabilite (miroir SupplierProcessor) : ne se
* declenche que si TOUS les champs requis sont presents dans le payload
* (= soumission d'onglet, pas un PATCH partiel cible). Delegue au validateur
* qui leve une 422 listant chaque champ manquant (mapping inline ERP-101).
*/
private function validateAccountingCompleteness(Provider $data): void
{
if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) {
return;
}
$this->accountingValidator->validate($data);
}
private function payloadKeys(): array
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return [];
}
$content = $request->getContent();
// Cache hit : meme corps brut que le dernier decodage -> memes cles.
if ($content === $this->decodedContent) {
return $this->decodedPayloadKeys;
}
$this->decodedContent = $content;
$this->decodedPayloadKeys = $this->extractPayloadKeys($content);
return $this->decodedPayloadKeys;
}
/**
* Decode le corps brut et en extrait les cles de premier niveau (chaines).
* Corps vide ou JSON invalide -> aucune cle.
*
* @return list<string>
*/
private function extractPayloadKeys(string $content): array
{
if ('' === $content) {
return [];
}
try {
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return [];
}
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
}
/**
* Leve une 422 portant une violation unique sur `sites` meme rendu Hydra que
* les contraintes Symfony, consommable inline par extractApiViolations (ERP-101).
*
* @return never
*/
private function throwSitesViolation(Provider $root): void
{
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Vous ne pouvez rattacher que des sites auxquels vous avez accès.',
null,
[],
$root,
'sites',
null,
));
throw new ValidationException($violations);
}
}
@@ -1,120 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderRib;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Processor d'ecriture de la sous-ressource RIB d'un prestataire (M3, spec-back
* § 4.5). Jumeau du SupplierRibProcessor (M2), recentre sur le perimetre ERP-135.
*
* Sequence :
* - POST / PATCH : rattachement au prestataire parent. Aucune normalisation
* specifique ; la validite de l'IBAN et du BIC est garantie par Assert\Iban /
* Assert\Bic sur l'entite (jouees en amont par API Platform). Aucun
* #[AuditIgnore] sur iban/bic : la tracabilite comptable est volontaire
* (decision M1/M2 reportee, spec § 2.7).
* - DELETE : RG-3.08 si le prestataire est en reglement LCR, la suppression de
* son DERNIER RIB est refusee (409), car LCR exige au moins un RIB.
*
* La security de l'operation (technique.providers.accounting.manage) est appliquee
* par API Platform en amont : un utilisateur sans cette permission recoit 403 sur
* POST/PATCH/DELETE avant d'atteindre ce processor — c'est le niveau de gating
* renforce des donnees bancaires (distinct de manage, spec § 4.5).
*
* @implements ProcessorInterface<ProviderRib, null|ProviderRib>
*/
final class ProviderRibProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly EntityManagerInterface $em,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof ProviderRib) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
$this->guardLastRibDeletionUnderLcr($data);
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache le RIB au prestataire parent de la sous-ressource POST
* (/providers/{providerId}/ribs) : la relation n'est pas peuplee
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
*/
private function linkParent(ProviderRib $rib, array $uriVariables): void
{
if (null !== $rib->getProvider()) {
return;
}
$providerId = $uriVariables['providerId'] ?? null;
if (null === $providerId) {
return;
}
$provider = $providerId instanceof Provider
? $providerId
: $this->em->getRepository(Provider::class)->find($providerId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
// contrainte provider_id NOT NULL).
if (!$provider instanceof Provider) {
throw new NotFoundHttpException('Prestataire introuvable.');
}
// Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
// sous-ressource sur un prestataire hors du perimetre de l'user -> 404
// (anti-enumeration, coherent avec le detail Provider garde en 404).
$this->scopeChecker->assertInScope($provider);
$rib->setProvider($provider);
}
/**
* RG-3.08 : un prestataire dont le type de reglement est LCR doit conserver au
* moins un RIB. La collection inclut le RIB en cours de suppression : un
* effectif <= 1 signifie qu'il ne resterait aucun RIB -> 409. Pour tout autre
* type de reglement, les RIBs sont optionnels (suppression libre).
*/
private function guardLastRibDeletionUnderLcr(ProviderRib $rib): void
{
$provider = $rib->getProvider();
if (null === $provider) {
return;
}
if ('LCR' === $provider->getPaymentType()?->getCode() && $provider->getRibs()->count() <= 1) {
throw new ConflictHttpException(
'Impossible de supprimer le dernier RIB : le type de règlement LCR exige au moins un RIB.',
);
}
}
}
@@ -1,223 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider du repertoire prestataires (M3). Cf. spec-back M3 § 4.1 / § 4.2 +
* RG-3.16 / RG-3.17. Jumeau du SupplierProvider (M2), augmente du cloisonnement
* par site pilote par l'utilisateur (§ 2.13).
*
* Collection (GET /api/providers) :
* - exclut par defaut les archives (is_archived = true) ET les soft-deletes
* (deleted_at IS NOT NULL) RG-3.16 ;
* - ?includeArchived=true reintegre les archives (les soft-deletes restent
* exclus au M3) RG-3.16 ;
* - tri par defaut companyName ASC RG-3.16 ;
* - filtres ?search=... (fuzzy companyName + contacts lies : firstName /
* lastName / email), ?categoryCode=<code> (prestataires ayant >= 1 categorie
* de ce code, repetable) et ?siteId=<id> (prestataires rattaches a ce site
* via la relation DIRECTE provider.sites, repetable) ;
* - pagination obligatoire (regle ABSOLUE n°13) : Paginator ORM ; echappatoire
* ?pagination=false pour alimenter un <select> sans pagination.
*
* Cloisonnement par site (RG-3.17, § 2.13) applique ICI (le QueryBuilder du
* repository ne connait pas l'user courant) :
* - si l'user N'A PAS `sites.bypass_scope` ET que CurrentSiteProvider::get()
* retourne un site -> la liste est restreinte aux prestataires dont
* provider.sites contient le currentSite (repository::applySiteScope), AVANT
* pagination : totalItems reflete le perimetre de l'user ;
* - le DETAIL (Get / provider de PATCH) d'un prestataire hors perimetre renvoie
* 404 (null) ne pas reveler l'existence d'une ligne hors site ;
* - user `bypass_scope` (Admin auto, profils consolidation) -> aucun filtre ;
* - currentSite = null (module Sites off / user sans site) -> no-op lecture
* (aligne site-aware.md § 5).
*
* Item (GET /api/providers/{id} + provider de PATCH) :
* - 404 si introuvable OU soft-delete (deleted_at non null, jamais expose au
* M3) ; les archives restent consultables/restaurables en detail ;
* - 404 si hors perimetre site (cloisonnement, cf. ci-dessus).
*
* Le filtrage des champs comptables en lecture (groupe provider:read:accounting)
* n'est PAS fait ici mais dans ProviderReadGroupContextBuilder : un Provider
* retourne des donnees mais ne peut pas influencer les groupes de serialisation.
*
* @implements ProviderInterface<Provider>
*/
final class ProviderProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
private readonly ProviderRepositoryInterface $repository,
private readonly Pagination $pagination,
// Decision de cloisonnement par site centralisee (site-aware.md § 6.2) :
// source UNIQUE partagee avec le provider decore des sous-ressources
// (ProviderSubResourceItemProvider) et les processors d'ecriture, pour
// eviter tout drift entre ces points d'application.
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Provider|null
{
if ($operation instanceof CollectionOperationInterface) {
return $this->provideCollection($operation, $context);
}
return $this->provideItem($uriVariables);
}
/**
* @param array<string, mixed> $context
*
* @return list<Provider>|Paginator<Provider>
*/
private function provideCollection(Operation $operation, array $context): array|Paginator
{
$filters = $context['filters'] ?? [];
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
$archivedOnly = $this->readBool($filters['archivedOnly'] ?? false);
$search = $filters['search'] ?? null;
// categoryCode accepte un code unique (?categoryCode=NETTOYAGE, selects)
// OU une liste (?categoryCode[]=A&categoryCode[]=B, drawer multi).
$categoryCodes = $this->readStringList($filters['categoryCode'] ?? []);
$siteIds = $this->readIntList($filters['siteId'] ?? []);
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
$qb = $this->repository->createListQueryBuilder(
$includeArchived,
is_string($search) ? $search : null,
$categoryCodes,
$siteIds,
$archivedOnly,
);
// Cloisonnement par site (RG-3.17) AVANT pagination : ajoute une clause
// restreignant au currentSite pour un user non-bypass. S'intersecte avec
// un eventuel filtre ?siteId du client (deux sous-requetes ANDees).
$scopeSite = $this->scopeChecker->siteScopeOrNull();
if (null !== $scopeSite) {
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
}
// Echappatoire ?pagination=false : collection complete sans Paginator
// (regle n°13 — utile pour un <select> cote front).
if (!$this->pagination->isEnabled($operation, $context)) {
/** @var list<Provider> $providers */
$providers = $qb->getQuery()->getResult();
// Hydratation batchee des collections affichees (§ 2.12) : evite le
// N+1 si la serialisation touche categories/sites, sans cartesien.
$this->repository->hydrateListCollections($providers);
return $providers;
}
$limit = $this->pagination->getLimit($operation, $context);
$page = max(1, $this->pagination->getPage($context));
$offset = ($page - 1) * $limit;
$qb->setFirstResult($offset)->setMaxResults($limit);
// Le QB de selection ne porte pas de fetch-join to-many (§ 2.12) : le
// COUNT est simple, fetchJoinCollection inutile. On materialise la page
// puis on hydrate ses collections en lot (memes entites managees).
$paginator = new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
$this->repository->hydrateListCollections(iterator_to_array($paginator));
return $paginator;
}
/**
* @param array<string, mixed> $uriVariables
*/
private function provideItem(array $uriVariables): ?Provider
{
$id = $uriVariables['id'] ?? null;
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$provider = $this->repository->findById((int) $id);
if (null === $provider) {
return null;
}
// Soft-delete : jamais expose au M3 (HP-M4) — 404 via retour null.
// Les archives restent visibles en detail (consultation + restauration).
if (null !== $provider->getDeletedAt()) {
return null;
}
// Cloisonnement par site (RG-3.17) : un prestataire hors du perimetre de
// l'user -> 404 (ne pas reveler son existence). No-op pour bypass_scope ou
// currentSite null (delegue au ProviderSiteScopeChecker).
if (!$this->scopeChecker->isInScope($provider)) {
return null;
}
return $provider;
}
/**
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
*/
private function readBool(mixed $raw): bool
{
if (is_bool($raw)) {
return $raw;
}
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
}
/**
* Normalise un filtre en liste de chaines. Tolere un code unique (string)
* ou une liste (?key[]=a&key[]=b). Trim + retrait des vides.
*
* @return list<string>
*/
private function readStringList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if (is_string($value) && '' !== trim($value)) {
$out[] = trim($value);
}
}
return $out;
}
/**
* Normalise un filtre en liste d'identifiants entiers positifs. Tolere une
* valeur unique ou une liste (?key[]=1&key[]=2).
*
* @return list<int>
*/
private function readIntList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
$out[] = (int) $value;
}
}
return $out;
}
}
@@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Technique\Domain\Entity\ProviderOwnedInterface;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider d'item des sous-ressources d'un prestataire (Contact / Adresse / RIB).
* Decore le provider Doctrine par defaut et applique le cloisonnement par site du
* PARENT (§ 2.13 / RG-3.17) sur Get / Patch / Delete.
*
* Sans ce garde, un user cloisonne pourrait lire / editer / supprimer une
* sous-ressource d'un prestataire hors de son site : le detail Provider est bien
* garde en 404 (ProviderProvider), mais les sous-ressources ne passent pas par lui
* (provider Doctrine par defaut, et SiteScopedQueryExtension ne filtre que les
* resources SiteAwareInterface ce que ces entites ne sont pas). Le RIB est
* particulierement sensible (IBAN / BIC).
*
* Hors perimetre -> retour null -> 404 (anti-enumeration, coherent avec le detail
* Provider). La decision de scope est deleguee a ProviderSiteScopeChecker (source
* unique partagee avec le ProviderProvider et les processors).
*
* @implements ProviderInterface<ProviderOwnedInterface>
*/
final class ProviderSubResourceItemProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.item_provider')]
private readonly ProviderInterface $itemProvider,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object
{
$entity = $this->itemProvider->provide($operation, $uriVariables, $context);
if ($entity instanceof ProviderOwnedInterface) {
$parent = $entity->getProvider();
if (null === $parent || !$this->scopeChecker->isInScope($parent)) {
return null;
}
}
return $entity;
}
}
@@ -1,328 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\Controller;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderContact;
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderProvider;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Export XLSX du repertoire prestataires (M3, spec-back § 4.6). Jumeau du
* `SupplierExportController` (M2, module Commercial), augmente du cloisonnement
* par site pilote par l'utilisateur (§ 2.13).
*
* Controller Symfony custom (et non operation API Platform) car il produit un
* binaire de fichier, pas une representation Hydra. `priority: 1` est
* OBLIGATOIRE sur la route : sans cela API Platform capterait
* `/api/providers/export.xlsx` comme l'item `GET /api/providers/{id}.{_format}`
* (id="export", _format="xlsx") cf. CLAUDE.md « controller custom sous /api ».
*
* Separation des responsabilites :
* - le COMMENT (generation du fichier) est delegue au service Shared
* {@see SpreadsheetExporterInterface} generique, reutilisable, sans metier ;
* - le QUOI vit ICI : selection des prestataires (memes filtres que
* `GET /api/providers`, via {@see ProviderRepositoryInterface::createListQueryBuilder()}),
* cloisonnement par site, et mapping metier des colonnes.
*
* Cloisonnement par site (RG-3.17, § 2.13) : replique la logique du
* {@see ProviderProvider}
* un user sans `sites.bypass_scope` et possedant un currentSite n'exporte que
* les prestataires rattaches a ce site (relation DIRECTE provider.sites). Le
* QueryBuilder ne connait pas l'user : la decision est prise ICI, le DQL dans le
* repository (applySiteScope).
*
* Colonnes de contact : alimentees par le CONTACT PRINCIPAL du prestataire le
* ProviderContact de plus petit `position` (decision D2, spec § 4.6).
*
* La colonne SIREN n'est ajoutee que si l'utilisateur a la permission
* `technique.providers.accounting.view` (gating identique a la lecture, § 2.9).
*/
#[AsController]
final class ProviderExportController
{
public function __construct(
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
private readonly ProviderRepositoryInterface $repository,
private readonly SpreadsheetExporterInterface $exporter,
private readonly Security $security,
// Outillage site-aware (cf. ProviderProvider) : resout le site courant pour
// appliquer le cloisonnement RG-3.17 a l'export comme a la liste.
private readonly CurrentSiteProviderInterface $currentSiteProvider,
) {}
#[Route('/api/providers/export.xlsx', name: 'technique_providers_export_xlsx', methods: ['GET'], priority: 1)]
#[IsGranted('technique.providers.view')]
public function __invoke(Request $request): Response
{
// Memes filtres d'archivage que la vue liste (ProviderProvider) pour que
// l'export reflete exactement ce que l'utilisateur voit a l'ecran :
// - includeArchived : inclut les archives en plus des actifs ;
// - archivedOnly : restreint aux seules archives (prioritaire, cf.
// createListQueryBuilder).
$includeArchived = $this->readBool($request->query->get('includeArchived'));
$archivedOnly = $this->readBool($request->query->get('archivedOnly'));
$search = $request->query->getString('search') ?: null;
// Memes filtres que la vue liste : categoryCode/siteId tolerent une valeur
// unique ou une liste (?categoryCode[]=A&siteId[]=1). On lit via all() pour
// ne pas lever d'exception sur une valeur scalaire.
$query = $request->query->all();
$categoryCodes = $this->readStringList($query['categoryCode'] ?? []);
$siteIds = $this->readIntList($query['siteId'] ?? []);
$qb = $this->repository
->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly)
;
// Cloisonnement par site (RG-3.17, § 2.13) AVANT materialisation : restreint
// au currentSite pour un user non-bypass (s'intersecte avec un eventuel
// ?siteId du client). No-op pour bypass_scope ou currentSite null.
$scopeSite = $this->siteScopeOrNull();
if (null !== $scopeSite) {
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
}
/** @var list<Provider> $providers */
$providers = $qb->getQuery()->getResult();
// Hydratation batchee des collections affichees (§ 2.12) : le QB de
// selection ne fetch-join pas les to-many. On remplit categories + sites en
// lot (colonnes « Catégories » / « Sites »), puis les contacts (colonnes du
// contact principal) — chacune en requetes IN bornees, anti N+1.
$this->repository->hydrateListCollections($providers);
$this->repository->hydrateContacts($providers);
$withSiren = $this->security->isGranted('technique.providers.accounting.view');
$binary = $this->exporter->export(
'Répertoire prestataires',
$this->buildHeaders($withSiren),
$this->buildRows($providers, $withSiren),
);
return $this->buildResponse($binary);
}
/**
* Site de cloisonnement a appliquer en LECTURE, ou null si aucun cloisonnement
* (user `sites.bypass_scope`, ou pas de site courant resolu module Sites off
* / user sans currentSite). Miroir de ProviderProvider::siteScopeOrNull().
*/
private function siteScopeOrNull(): ?SiteInterface
{
if ($this->security->isGranted('sites.bypass_scope')) {
return null;
}
return $this->currentSiteProvider->get();
}
/**
* Colonnes de l'export (spec § 4.6). SIREN inseree avant la date de creation,
* uniquement si l'utilisateur a accounting.view.
*
* @return list<string>
*/
private function buildHeaders(bool $withSiren): array
{
$headers = [
'Nom prestataire',
'Contact principal',
'Téléphone principal',
'Téléphone secondaire',
'Email',
'Catégories',
'Sites',
];
if ($withSiren) {
$headers[] = 'SIREN';
}
$headers[] = 'Date de création';
return $headers;
}
/**
* @param list<Provider> $providers
*
* @return iterable<list<null|scalar>>
*/
private function buildRows(array $providers, bool $withSiren): iterable
{
foreach ($providers as $provider) {
$contact = $this->principalContact($provider);
$row = [
$provider->getCompanyName(),
null !== $contact ? $this->formatContactName($contact) : '',
$contact?->getPhonePrimary() ?? '',
$contact?->getPhoneSecondary() ?? '',
$contact?->getEmail() ?? '',
$this->formatCategories($provider),
$this->formatSites($provider),
];
if ($withSiren) {
$row[] = $provider->getSiren();
}
$row[] = $provider->getCreatedAt()?->format('d/m/Y');
yield $row;
}
}
/**
* Contact principal du prestataire : le ProviderContact de plus petit
* `position` (decision D2, spec § 4.6). Null si le prestataire n'a aucun
* contact (les colonnes contact restent vides).
*/
private function principalContact(Provider $provider): ?ProviderContact
{
$contacts = $provider->getContacts()->toArray();
if ([] === $contacts) {
return null;
}
usort(
$contacts,
static fn (ProviderContact $a, ProviderContact $b): int => $a->getPosition() <=> $b->getPosition(),
);
return $contacts[0];
}
/**
* Libelle du contact principal « Nom Prénom » (spec § 4.6). Les deux parties
* sont optionnelles (RG-3.04 : au moins l'une des deux), d'ou le trim final.
*/
private function formatContactName(ProviderContact $contact): string
{
return trim(sprintf('%s %s', $contact->getLastName() ?? '', $contact->getFirstName() ?? ''));
}
/**
* Libelles des categories du prestataire, dedupliques, tries, joints par
* virgule.
*/
private function formatCategories(Provider $provider): string
{
$names = [];
foreach ($provider->getCategories() as $category) {
// @var CategoryInterface $category
$name = $category->getName();
if (null !== $name && '' !== $name) {
$names[$name] = true;
}
}
return $this->joinSorted($names);
}
/**
* Sites du prestataire (relation DIRECTE provider.sites, RG-3.03 contrairement
* au fournisseur M2 dont les sites sont portes par les adresses). La colonne
* « Sites » agrege l'union distincte des sites rattaches.
*/
private function formatSites(Provider $provider): string
{
$names = [];
foreach ($provider->getSites() as $site) {
// @var SiteInterface $site
$name = $site->getName();
if (null !== $name && '' !== $name) {
$names[$name] = true;
}
}
return $this->joinSorted($names);
}
/**
* @param array<string, true> $names ensemble de libelles (cles)
*/
private function joinSorted(array $names): string
{
$list = array_keys($names);
sort($list);
return implode(', ', $list);
}
private function buildResponse(string $binary): Response
{
$filename = sprintf('repertoire-prestataires-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
$response = new Response($binary);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
return $response;
}
/**
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
* Aligne sur ProviderProvider pour un comportement identique a la liste.
*/
private function readBool(mixed $raw): bool
{
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
}
/**
* Normalise un filtre en liste de chaines (valeur unique ou liste).
* Aligne sur ProviderProvider pour un comportement identique a la liste.
*
* @return list<string>
*/
private function readStringList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if (is_string($value) && '' !== trim($value)) {
$out[] = trim($value);
}
}
return $out;
}
/**
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
* ou liste). Aligne sur ProviderProvider.
*
* @return list<int>
*/
private function readIntList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
$out[] = (int) $value;
}
}
return $out;
}
}
@@ -1,393 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\DataFixtures;
use App\Module\Catalog\Infrastructure\DataFixtures\CategoryFixtures;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
use App\Module\Commercial\Infrastructure\DataFixtures\CommercialReferentialFixtures;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderAddress;
use App\Module\Technique\Domain\Entity\ProviderContact;
use App\Module\Technique\Domain\Entity\ProviderRib;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SiteProviderInterface;
use DateTimeImmutable;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use RuntimeException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Fixtures dev/test du module Technique : prestataires de demonstration couvrant
* les cas metier RG-3.xx du repertoire prestataires (M3), jumelles des fixtures
* fournisseurs (M2). Theme : prestations techniques (maintenance, nettoyage,
* transport).
*
* Cas pivots couverts (§ 8.4) :
* - prestataire COMPLET : >= 1 site sur le formulaire principal (RG-3.03), >= 1
* contact, >= 1 adresse multi-sites (RG-3.05), comptabilite + RIB ;
* - reglement LCR avec RIB (RG-3.08) ; reglement VIREMENT avec banque (RG-3.07) ;
* - 1 prestataire archive (isArchived + archivedAt) pour l'exclusion de la liste
* (RG-3.16) ;
* - prestataires repartis sur des sites DIFFERENTS (86 / 17 / 82) pour exercer le
* cloisonnement par site (RG-3.17) ;
* - mono et multi-categories de type PRESTATAIRE (RG-3.09).
*
* Resolution inter-modules conforme a la regle n°1 (pas d'import de logique) :
* - categories resolues via le contrat Shared CategoryInterface ;
* - sites resolus via le contrat Shared SiteProviderInterface.
*
* Normalisation : valeurs fournies BRUTES, normalisees par ProviderFieldNormalizer
* avant persist, exactement comme le ferait le ProviderProcessor via l'API
* (companyName UPPERCASE, first/last Capitalize, telephones chiffres seuls, emails
* lowercase RG-3.11).
*
* Idempotence : lookup par companyName normalise (coherent avec l'index unique
* partiel uq_provider_company_name_active). Un prestataire deja present n'est pas
* reconstruit (sous-collections non redupliquees). Rejouable sans doublon.
*
* Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`, la
* fixture ne charge rien : les tests seedent et nettoient leurs propres
* prestataires et comptent sur une table `provider` vierge. Meme garde-fou que
* SupplierFixtures / CategoryFixtures.
*/
class ProviderFixtures extends Fixture implements DependentFixtureInterface
{
/**
* Type de categorie exige pour un prestataire et ses adresses (RG-3.09).
* Miroir de Provider::REQUIRED_CATEGORY_TYPE_CODE (non importable regle n°1).
*/
private const string PROVIDER_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
/** Cache des categories resolues par nom. */
private array $categoryCache = [];
/** Cache des sites resolus par nom. */
private array $siteCache = [];
/** ObjectManager courant, capture en debut de load. */
private ObjectManager $manager;
public function __construct(
private readonly ProviderFieldNormalizer $normalizer,
private readonly SiteProviderInterface $siteProvider,
#[Autowire('%kernel.environment%')]
private readonly string $environment,
) {}
/**
* @return array<int, class-string>
*/
public function getDependencies(): array
{
return [
CategoryFixtures::class,
SitesFixtures::class,
CommercialReferentialFixtures::class,
];
}
public function load(ObjectManager $manager): void
{
// Donnees de demo : dev uniquement. En test, on laisse la table vierge.
if ('test' === $this->environment) {
return;
}
$this->manager = $manager;
// === Prestataire COMPLET — VIREMENT + banque (RG-3.07), compta + RIB, ===
// === multi-sites sur le formulaire principal ET sur l'adresse. ===
[$maintenance, $isNew] = $this->ensureProvider($manager, 'Maintenance Pro SAS', ['Maintenance industrielle'], ['Chatellerault', 'Saint-Jean']);
if ($isNew) {
$maintenance->setSiren('841611054');
$maintenance->setAccountNumber('P0001');
$maintenance->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$maintenance->setNTva('FR12841611054');
$maintenance->setPaymentDelay($this->paymentDelay($manager, 'J30'));
$maintenance->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$maintenance->setBank($this->bank($manager, 'SG'));
$this->addContact($maintenance, 'Marie', 'Martin', 'Responsable', '05 49 00 00 01', null, 'marie.martin@maintenance-pro.fr');
$this->addAddress($maintenance, ['Chatellerault', 'Saint-Jean'], '86000', 'Poitiers', '12 rue des Acacias', categoryNames: ['Maintenance industrielle']);
$this->addRib($maintenance, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0);
}
// === LCR avec RIB (RG-3.08) — site Pommevic ===
[$nettoyage, $isNew] = $this->ensureProvider($manager, 'Nettoyage Sud-Ouest', ['Nettoyage'], ['Pommevic']);
if ($isNew) {
$nettoyage->setSiren('775680459');
$nettoyage->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$nettoyage->setPaymentDelay($this->paymentDelay($manager, 'J15'));
$nettoyage->setPaymentType($this->paymentType($manager, 'LCR'));
$this->addContact($nettoyage, 'Sophie', 'Marchand', 'Directrice', '05 56 10 20 30', '06 11 22 33 44', 'sophie.marchand@nettoyage-so.fr', 0);
$this->addContact($nettoyage, 'Marc', 'Girard', 'Chef d\'equipe', '05 56 10 20 31', null, 'marc.girard@nettoyage-so.fr', 1);
$this->addAddress($nettoyage, ['Pommevic'], '82400', 'Pommevic', '8 route des Prestations');
$this->addRib($nettoyage, 'Compte principal', 'BNPAFRPPXXX', 'FR7630006000011234567890189', 0);
}
// === Multi-categories PRESTATAIRE + reglement CHEQUE (sans banque ni RIB) ===
[$transport, $isNew] = $this->ensureProvider($manager, 'Transport Express Atlantique', ['Transport', 'Maintenance industrielle'], ['Saint-Jean']);
if ($isNew) {
$transport->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
$transport->setPaymentType($this->paymentType($manager, 'CHEQUE'));
$this->addContact($transport, 'Thomas', 'Petit', 'Responsable logistique', '05 56 31 32 33', null, 'thomas.petit@transport-express.fr');
$this->addAddress($transport, ['Saint-Jean'], '17400', 'Fontenet', '4 zone des Transporteurs', categoryNames: ['Transport']);
}
// === Prestataire minimal — contact par le seul nom (RG-3.04), site 86 ===
[$petit, $isNew] = $this->ensureProvider($manager, 'Atelier Soudure Locale', ['Maintenance industrielle'], ['Chatellerault']);
if ($isNew) {
$this->addContact($petit, null, 'Caron', 'Gerant', '05 49 81 82 83', null, 'contact@atelier-soudure.fr');
$this->addAddress($petit, ['Chatellerault'], '86100', 'Châtellerault', '6 chemin de l\'Atelier');
}
// === Prestataire archive (RG-3.16) ===
[$ancien, $isNew] = $this->ensureProvider($manager, 'Ancien Prestataire Ferme', ['Nettoyage'], ['Chatellerault'], isArchived: true);
if ($isNew) {
$this->addContact($ancien, null, 'Lambert', 'Ancien contact', '05 49 99 99 99', null, 'contact@ancien-prestataire.fr');
$this->addAddress($ancien, ['Chatellerault'], '86100', 'Châtellerault', '99 rue Fermée');
}
$manager->flush();
}
/**
* Cree un prestataire (base normalisee + categories PRESTATAIRE + sites directs)
* s'il n'existe pas, sinon retourne l'existant. Retourne [Provider, isNew] :
* isNew=false bloque la reconstruction des sous-collections (idempotence).
*
* @param list<string> $categoryNames categories de type PRESTATAIRE (RG-3.09)
* @param list<string> $siteNames sites du formulaire principal (RG-3.03, >= 1)
*
* @return array{0: Provider, 1: bool}
*/
private function ensureProvider(
ObjectManager $manager,
string $companyName,
array $categoryNames,
array $siteNames,
bool $isArchived = false,
): array {
$normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName);
$existing = $manager->getRepository(Provider::class)->findOneBy(['companyName' => $normalizedName]);
if ($existing instanceof Provider) {
return [$existing, false];
}
$provider = new Provider();
$provider->setCompanyName($normalizedName);
foreach ($categoryNames as $categoryName) {
$provider->addCategory($this->category($manager, $categoryName));
}
foreach ($siteNames as $siteName) {
$provider->addSite($this->site($siteName));
}
if ($isArchived) {
$provider->setIsArchived(true);
$provider->setArchivedAt(new DateTimeImmutable());
}
$manager->persist($provider);
return [$provider, true];
}
/**
* Ajoute un contact normalise au prestataire (cascade persist via
* Provider.contacts). Au moins un champ est rempli (RG-3.04).
*/
private function addContact(
Provider $provider,
?string $firstName,
?string $lastName,
?string $jobTitle,
?string $phonePrimary,
?string $phoneSecondary,
?string $email,
int $position = 0,
): void {
$contact = new ProviderContact();
$contact->setProvider($provider);
$contact->setFirstName($this->normalizer->normalizePersonName($firstName));
$contact->setLastName($this->normalizer->normalizePersonName($lastName));
$contact->setJobTitle($jobTitle);
$contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary));
$contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary));
$contact->setEmail($this->normalizer->normalizeEmail($email));
$contact->setPosition($position);
$provider->addContact($contact);
}
/**
* Ajoute une adresse au prestataire (cascade persist via Provider.addresses).
* Adresse simplifiee M3 : PAS de addressType / bennes / triageProvider. Au
* moins un site est rattache (RG-3.05) ; categories d'adresse de type
* PRESTATAIRE (RG-3.09).
*
* @param list<string> $siteNames au moins un site (RG-3.05)
* @param list<string> $categoryNames categories de type PRESTATAIRE (RG-3.09)
*/
private function addAddress(
Provider $provider,
array $siteNames,
string $postalCode,
string $city,
string $street,
?string $streetComplement = null,
array $categoryNames = [],
int $position = 0,
): void {
$address = new ProviderAddress();
$address->setProvider($provider);
$address->setCountry('France');
$address->setPostalCode($postalCode);
$address->setCity($city);
$address->setStreet($street);
$address->setStreetComplement($streetComplement);
$address->setPosition($position);
foreach ($siteNames as $siteName) {
$address->addSite($this->site($siteName));
}
foreach ($categoryNames as $categoryName) {
$address->addCategory($this->category($this->manager, $categoryName));
}
$provider->addAddress($address);
}
/**
* Ajoute un RIB au prestataire (cascade persist via Provider.ribs).
*/
private function addRib(Provider $provider, string $label, string $bic, string $iban, int $position = 0): void
{
$rib = new ProviderRib();
$rib->setProvider($provider);
$rib->setLabel($label);
$rib->setBic($bic);
$rib->setIban($iban);
$rib->setPosition($position);
$provider->addRib($rib);
}
/**
* Resout une categorie par son nom via le contrat Shared CategoryInterface,
* sans importer le module Catalog (regle n°1). Verifie le type PRESTATAIRE
* (RG-3.09). Mise en cache par nom.
*/
private function category(ObjectManager $manager, string $name): CategoryInterface
{
if (isset($this->categoryCache[$name])) {
return $this->categoryCache[$name];
}
$candidates = $manager->getRepository(CategoryInterface::class)->findBy([
'name' => $name,
'deletedAt' => null,
]);
foreach ($candidates as $candidate) {
if ($candidate instanceof CategoryInterface
&& in_array(self::PROVIDER_CATEGORY_TYPE_CODE, $candidate->getCategoryTypeCodes(), true)) {
return $this->categoryCache[$name] = $candidate;
}
}
throw new RuntimeException(sprintf(
'Categorie PRESTATAIRE "%s" introuvable : CategoryFixtures doit tourner avant ProviderFixtures.',
$name,
));
}
/**
* Resout un site par son nom via le contrat Shared SiteProviderInterface, sans
* importer le module Sites (regle n°1). Mise en cache par nom.
*/
private function site(string $name): SiteInterface
{
if (isset($this->siteCache[$name])) {
return $this->siteCache[$name];
}
$site = $this->siteProvider->findByName($name);
if (!$site instanceof SiteInterface) {
throw new RuntimeException(sprintf(
'Site "%s" introuvable : SitesFixtures doit tourner avant ProviderFixtures.',
$name,
));
}
return $this->siteCache[$name] = $site;
}
private function tvaMode(ObjectManager $manager, string $code): TvaMode
{
$mode = $manager->getRepository(TvaMode::class)->findOneBy(['code' => $code]);
if (!$mode instanceof TvaMode) {
throw new RuntimeException(sprintf(
'TvaMode "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.',
$code,
));
}
return $mode;
}
private function paymentDelay(ObjectManager $manager, string $code): PaymentDelay
{
$delay = $manager->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]);
if (!$delay instanceof PaymentDelay) {
throw new RuntimeException(sprintf(
'PaymentDelay "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.',
$code,
));
}
return $delay;
}
private function paymentType(ObjectManager $manager, string $code): PaymentType
{
$type = $manager->getRepository(PaymentType::class)->findOneBy(['code' => $code]);
if (!$type instanceof PaymentType) {
throw new RuntimeException(sprintf(
'PaymentType "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.',
$code,
));
}
return $type;
}
private function bank(ObjectManager $manager, string $code): Bank
{
$bank = $manager->getRepository(Bank::class)->findOneBy(['code' => $code]);
if (!$bank instanceof Bank) {
throw new RuntimeException(sprintf(
'Bank "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.',
$code,
));
}
return $bank;
}
}
@@ -1,287 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\Doctrine;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Provider>
*/
class DoctrineProviderRepository extends ServiceEntityRepository implements ProviderRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Provider::class);
}
public function findById(int $id): ?Provider
{
return $this->find($id);
}
public function save(Provider $provider): void
{
$this->getEntityManager()->persist($provider);
$this->getEntityManager()->flush();
}
public function createListQueryBuilder(
bool $includeArchived = false,
?string $search = null,
array $categoryCodes = [],
array $siteIds = [],
bool $archivedOnly = false,
): QueryBuilder {
// SELECTION uniquement (filtres + tri) : pas de fetch-join to-many ici.
// L'hydratation des collections affichees (Catégories / Site(s)) est
// deleguee a hydrateListCollections() une fois le jeu borne, pour ne pas
// imposer un produit cartesien aux chemins non pagines (export,
// ?pagination=false) — § 2.12 (cf. M1/ERP-100, M2).
$qb = $this->createQueryBuilder('p')
->andWhere('p.deletedAt IS NULL')
->orderBy('p.companyName', 'ASC')
;
// Perimetre d'archivage : archivedOnly prioritaire sur includeArchived.
if ($archivedOnly) {
$qb->andWhere('p.isArchived = true');
} elseif (!$includeArchived) {
$qb->andWhere('p.isArchived = false');
}
$this->applySearch($qb, $search);
$this->applyCategoryCodes($qb, $categoryCodes);
$this->applySiteIds($qb, $siteIds);
return $qb;
}
public function hydrateListCollections(array $providers): void
{
$ids = $this->collectIds($providers);
if ([] === $ids) {
return;
}
// 1re passe : categories (colonne « Catégories »). Produit p x cat seul.
$this->createQueryBuilder('p')
->leftJoin('p.categories', 'cat')->addSelect('cat')
->where('p.id IN (:ids)')->setParameter('ids', $ids)
->getQuery()
->getResult()
;
// 2e passe : sites (colonne « Site(s) »). SPECIFICITE M3 : les sites sont
// portes DIRECTEMENT par le prestataire (provider.sites, RG-3.03), pas via
// les adresses comme au M2 — d'ou un simple join p -> site (pas d'imbrication
// addr -> site). Separer des categories casse le cartesien cat x site.
$this->createQueryBuilder('p')
->leftJoin('p.sites', 'site')->addSelect('site')
->where('p.id IN (:ids)')->setParameter('ids', $ids)
->getQuery()
->getResult()
;
}
public function applySiteScope(QueryBuilder $qb, int $siteId): void
{
// Cloisonnement par site (RG-3.17, § 2.13) : ne garder que les prestataires
// dont provider.sites contient le site donne. Sous-requete IN (alias p5
// distinct des filtres p2/p3/p4) pour ne pas perturber le tri/pagination du
// QueryBuilder principal — meme parti pris que applyCategoryCodes / applySiteIds.
// Parametre :scopeSiteId distinct de :siteIds (filtre ?siteId du client) pour
// que les deux clauses puissent coexister (intersection) sans collision.
$sub = $this->getEntityManager()->createQueryBuilder()
->select('p5.id')
->from(Provider::class, 'p5')
->join('p5.sites', 'site5')
->where('site5.id = :scopeSiteId')
;
$qb->andWhere($qb->expr()->in('p.id', $sub->getDQL()))
->setParameter('scopeSiteId', $siteId)
;
}
public function hydrateContacts(array $providers): void
{
$ids = $this->collectIds($providers);
if ([] === $ids) {
return;
}
// Une seule requete IN bornee : remplit la collection `contacts` des MEMES
// instances Provider (identity map). Tri par position pour que le « contact
// principal » (plus petit position) soit deterministe a l'export.
$this->createQueryBuilder('p')
->leftJoin('p.contacts', 'pc')->addSelect('pc')
->where('p.id IN (:ids)')->setParameter('ids', $ids)
->orderBy('pc.position', 'ASC')
->getQuery()
->getResult()
;
}
/**
* Recherche fuzzy insensible a la casse sur companyName ET sur les contacts
* lies (firstName / lastName / email) miroir M2. Les deux criteres sont unis
* par OR : un prestataire matche si son nom de societe OU l'un de ses contacts
* matche. Le critere contact passe par une sous-requete IN (plutot qu'un JOIN
* sur la collection) pour ne pas perturber le DISTINCT / ORDER BY / pagination
* principal. Les metacaracteres LIKE (%, _, \) saisis sont 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').'%';
$contactSub = $this->getEntityManager()->createQueryBuilder()
->select('p2.id')
->from(Provider::class, 'p2')
->join('p2.contacts', 'pc2')
->where('LOWER(pc2.firstName) LIKE :search')
->orWhere('LOWER(pc2.lastName) LIKE :search')
->orWhere('LOWER(pc2.email) LIKE :search')
;
$qb->andWhere(
$qb->expr()->orX(
'LOWER(p.companyName) LIKE :search',
$qb->expr()->in('p.id', $contactSub->getDQL()),
),
)->setParameter('search', $pattern);
}
/**
* Restreint aux prestataires possedant au moins une categorie dont le code
* figure dans la liste (OR). Alimente le filtre « Catégories » du drawer.
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
* perturber le DISTINCT / ORDER BY principal.
*
* @param list<string> $categoryCodes
*/
private function applyCategoryCodes(QueryBuilder $qb, array $categoryCodes): void
{
$codes = $this->normalizeStringList($categoryCodes);
if ([] === $codes) {
return;
}
$sub = $this->getEntityManager()->createQueryBuilder()
->select('p3.id')
->from(Provider::class, 'p3')
->join('p3.categories', 'cat3')
->where('cat3.code IN (:categoryCodes)')
;
$qb->andWhere($qb->expr()->in('p.id', $sub->getDQL()))
->setParameter('categoryCodes', $codes)
;
}
/**
* Restreint aux prestataires rattaches a l'un des sites donnes (OR). SPECIFICITE
* M3 : les sites sont portes DIRECTEMENT par le prestataire (provider.sites,
* RG-3.03), d'ou une sous-requete sur p.sites (et non sur les adresses comme au
* M2). Sous-requete IN pour ne pas perturber le tri/pagination principal.
*
* @param list<int> $siteIds
*/
private function applySiteIds(QueryBuilder $qb, array $siteIds): void
{
$ids = $this->normalizeIntList($siteIds);
if ([] === $ids) {
return;
}
$sub = $this->getEntityManager()->createQueryBuilder()
->select('p4.id')
->from(Provider::class, 'p4')
->join('p4.sites', 'site4')
->where('site4.id IN (:siteIds)')
;
$qb->andWhere($qb->expr()->in('p.id', $sub->getDQL()))
->setParameter('siteIds', $ids)
;
}
/**
* Extrait les identifiants non nuls d'un jeu de prestataires (entites managees).
* Les requetes d'hydratation renvoient les MEMES instances Provider (identity
* map), dont les collections sont alors remplies anti N+1 a la serialisation.
*
* @param list<Provider> $providers
*
* @return list<int>
*/
private function collectIds(array $providers): array
{
$ids = [];
foreach ($providers as $provider) {
$id = $provider->getId();
if (null !== $id) {
$ids[] = $id;
}
}
return $ids;
}
/**
* Nettoie une liste de chaines : trim, retrait des vides, reindexation.
* Defensive : tolere des elements scalaires non-string (cast) et ignore le
* reste sans lever de TypeError, le contrat etant de normaliser une entree
* potentiellement brute (query params).
*
* @param array<mixed> $values
*
* @return list<string>
*/
private function normalizeStringList(array $values): array
{
$out = [];
foreach ($values as $value) {
if (is_string($value) || is_int($value) || is_float($value)) {
$trimmed = trim((string) $value);
if ('' !== $trimmed) {
$out[] = $trimmed;
}
}
}
return $out;
}
/**
* Nettoie une liste d'identifiants : cast int, retrait des <= 0, reindexation.
* Defensive (cf. normalizeStringList) : accepte des entiers ou des chaines
* numeriques ('1', '2') sans TypeError, ignore le reste.
*
* @param array<mixed> $values
*
* @return list<int>
*/
private function normalizeIntList(array $values): array
{
$out = [];
foreach ($values as $value) {
if (is_numeric($value) && (int) $value > 0) {
$out[] = (int) $value;
}
}
return $out;
}
}
@@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\Security;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Shared\Domain\Contract\SiteInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Decision centralisee du cloisonnement par site des prestataires (§ 2.13 /
* RG-3.17). Source UNIQUE partagee par le ProviderProvider (liste + detail), le
* provider decore des sous-ressources (ProviderSubResourceItemProvider) et les
* processors d'ecriture des sous-ressources — afin d'eviter tout drift entre ces
* points d'application.
*
* Regle : un user SANS `sites.bypass_scope` ET avec un site courant ne voit /
* n'opere que sur les prestataires rattaches (relation directe provider.sites) a
* son site courant. `bypass_scope` (Admin inclus via isAdmin) ou absence de site
* courant (module Sites off / user sans currentSite) -> aucun cloisonnement
* (no-op, aligne site-aware.md § 5).
*/
final class ProviderSiteScopeChecker
{
public function __construct(
private readonly Security $security,
private readonly CurrentSiteProviderInterface $currentSiteProvider,
) {}
/**
* Site de cloisonnement a appliquer, ou null si aucun cloisonnement
* (`bypass_scope`, ou pas de site courant resolu).
*/
public function siteScopeOrNull(): ?SiteInterface
{
if ($this->security->isGranted('sites.bypass_scope')) {
return null;
}
return $this->currentSiteProvider->get();
}
/**
* Vrai si le prestataire est dans le perimetre site de l'user courant ou si
* aucun cloisonnement ne s'applique.
*/
public function isInScope(Provider $provider): bool
{
$scopeSite = $this->siteScopeOrNull();
if (null === $scopeSite) {
return true;
}
return $this->providerHasSite($provider, (int) $scopeSite->getId());
}
/**
* Leve un 404 si le prestataire est hors perimetre (anti-enumeration : ne pas
* reveler l'existence d'une ligne hors site). No-op si dans le perimetre.
*/
public function assertInScope(Provider $provider): void
{
if (!$this->isInScope($provider)) {
throw new NotFoundHttpException('Prestataire introuvable.');
}
}
/**
* Vrai si le prestataire est rattache (relation directe provider.sites) au site
* d'id donne. Comparaison en memoire sur l'entite deja chargee.
*/
private function providerHasSite(Provider $provider, int $siteId): bool
{
foreach ($provider->getSites() as $site) {
if ($site instanceof SiteInterface && $site->getId() === $siteId) {
return true;
}
}
return false;
}
}
@@ -1,219 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Application\Idtf;
use RuntimeException;
use function array_slice;
/**
* Parsing pur d'une matrice (lignes/colonnes 0-indexees, telle que retournee
* par PhpSpreadsheet::toArray) de l'export Excel IDTF vers des lignes
* normalisees pretes a l'upsert. Sans dependance a PhpSpreadsheet : la matrice
* est un simple tableau, ce qui rend le parsing testable en isolation.
*
* Robuste au reordonnancement des colonnes (mapping par libelle normalise) et
* aux lignes de preambule (detection dynamique de la ligne d'en-tete). Voir
* ERP-149 § 2.
*/
final class IdtfSheetParser
{
/**
* @param array<int, array<int, mixed>> $matrix
*
* @return array{exportDate: null|string, rows: list<array<string, mixed>>}
*/
public static function parse(array $matrix): array
{
$exportDate = self::extractExportDate($matrix);
$headerIndex = self::findHeaderIndex($matrix);
if (null === $headerIndex) {
throw new RuntimeException("Ligne d'en-tete introuvable (colonne 'Numero IDTF').");
}
$map = self::buildColumnMap($matrix[$headerIndex]);
if (!isset($map['idtf_number'])) {
throw new RuntimeException("Colonne 'Numero IDTF' introuvable dans l'en-tete.");
}
$rows = [];
foreach (array_slice($matrix, $headerIndex + 1) as $row) {
$idtf = trim((string) ($row[$map['idtf_number']] ?? ''));
// Ligne vide / non exploitable : pas d'identifiant numerique.
if ('' === $idtf || !ctype_digit($idtf)) {
continue;
}
$rows[] = [
'idtf_number' => (int) $idtf,
'product_group' => self::val($row, $map['product_group'] ?? null),
'name' => self::val($row, $map['name'] ?? null) ?? '',
'cleaning_regime' => self::val($row, $map['cleaning_regime'] ?? null) ?? '',
'important_requirements' => self::val($row, $map['important_requirements'] ?? null),
'mandatory_date' => self::parseDate(self::val($row, $map['mandatory_date'] ?? null)),
'related_products' => self::val($row, $map['related_products'] ?? null),
'formula' => self::val($row, $map['formula'] ?? null),
'eural_code' => self::val($row, $map['eural_code'] ?? null),
'cas_numbers' => self::splitCas(self::val($row, $map['cas'] ?? null)),
'footnotes' => self::val($row, $map['footnotes'] ?? null),
];
}
return ['exportDate' => $exportDate, 'rows' => $rows];
}
/**
* Cherche une date "d-m-Y" dans les premieres lignes (preambule
* "Export date: 12-6-2026") et la convertit en "Y-m-d". Null si absente.
*
* @param array<int, array<int, mixed>> $matrix
*/
public static function extractExportDate(array $matrix): ?string
{
foreach (array_slice($matrix, 0, 5) as $row) {
$line = implode(' ', array_map(static fn (mixed $c): string => (string) $c, $row));
if (preg_match('/(\d{1,2})-(\d{1,2})-(\d{4})/', $line, $m)) {
$day = (int) $m[1];
$month = (int) $m[2];
$year = (int) $m[3];
if (checkdate($month, $day, $year)) {
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
}
}
return null;
}
/**
* Index de la ligne d'en-tete : premiere ligne contenant une cellule dont
* le libelle normalise contient "numero idtf".
*
* @param array<int, array<int, mixed>> $matrix
*/
private static function findHeaderIndex(array $matrix): ?int
{
foreach ($matrix as $i => $row) {
foreach ($row as $cell) {
if (str_contains(self::normalize((string) $cell), 'numero idtf')) {
return $i;
}
}
}
return null;
}
/**
* Construit le mapping logique -> index de colonne a partir de la ligne
* d'en-tete (resiste au reordonnancement via fields[]).
*
* @param array<int, mixed> $header
*
* @return array<string, int>
*/
private static function buildColumnMap(array $header): array
{
$map = [];
foreach ($header as $col => $label) {
$n = self::normalize((string) $label);
$key = match (true) {
str_contains($n, 'numero idtf') => 'idtf_number',
str_contains($n, 'product group'),
str_contains($n, 'groupe') => 'product_group',
str_contains($n, 'nom de la marchandise') => 'name',
str_contains($n, 'regime de nettoyage') => 'cleaning_regime',
str_contains($n, 'exigences importantes') => 'important_requirements',
str_contains($n, 'date d application') => 'mandatory_date',
str_contains($n, 'produits apparentes') => 'related_products',
str_contains($n, 'formule') => 'formula',
str_contains($n, 'code eural') => 'eural_code',
str_contains($n, 'numero cas') => 'cas',
str_contains($n, 'annotations') => 'footnotes',
default => null,
};
if (null !== $key && !isset($map[$key])) {
$map[$key] = (int) $col;
}
}
return $map;
}
/**
* Convertit une date "dd-mm-yyyy" en "yyyy-mm-dd". Null si format invalide
* ou date calendaire impossible.
*/
private static function parseDate(?string $raw): ?string
{
if (null === $raw || !preg_match('/^(\d{1,2})-(\d{1,2})-(\d{4})$/', $raw, $m)) {
return null;
}
$day = (int) $m[1];
$month = (int) $m[2];
$year = (int) $m[3];
if (!checkdate($month, $day, $year)) {
return null;
}
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
/**
* Eclate une cellule "Numero CAS" sur ';' en liste de chaines non vides.
*
* @return list<string>
*/
private static function splitCas(?string $raw): array
{
if (null === $raw) {
return [];
}
$parts = array_map('trim', explode(';', $raw));
return array_values(array_filter($parts, static fn (string $v): bool => '' !== $v));
}
/**
* Valeur d'une cellule par index : trim, null si absente/vide.
*
* @param array<int, mixed> $row
*/
private static function val(array $row, ?int $col): ?string
{
if (null === $col) {
return null;
}
$v = trim((string) ($row[$col] ?? ''));
return '' === $v ? null : $v;
}
/**
* Normalise un libelle d'en-tete : minuscules, sans accents ni apostrophes,
* espaces compresses (pour un matching robuste).
*/
private static function normalize(string $s): string
{
$s = str_replace(['', "'"], ' ', $s);
$s = (string) iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
$s = mb_strtolower($s);
return trim((string) preg_replace('/\s+/', ' ', $s));
}
}
@@ -1,130 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Application\Qualimat;
/**
* Mapping pur d'un item brut de l'API QUALIMAT vers une ligne normalisee
* prete a l'upsert dans `qualimat_carrier`. Sans dependance (testable en
* isolation). Voir ERP-39 § 2 pour les pieges qualite de la source.
*/
final class QualimatRowMapper
{
/**
* Mappe un lot d'items. Les items sans SIRET exploitable sont ignores et
* comptes a part (cf. `rows_skipped` du journal). Les doublons de SIRET
* (source "sale" : memes chiffres a separateurs pres) sont fusionnes,
* derniere occurrence gagnante l'upsert ne verrait qu'une ligne de toute
* facon, et le compte `rows_upserted` reflete ainsi les transporteurs
* distincts.
*
* @param array<int, array<string, mixed>> $items
*
* @return array{rows: list<array<string, mixed>>, skipped: int}
*/
public static function mapMany(array $items): array
{
$bySiret = [];
$skipped = 0;
foreach ($items as $item) {
$row = self::mapOne($item);
if (null === $row) {
++$skipped;
continue;
}
// Cle = SIRET normalise : une occurrence ulterieure ecrase la
// precedente (derniere gagnante).
$bySiret[$row['siret']] = $row;
}
return ['rows' => array_values($bySiret), 'skipped' => $skipped];
}
/**
* Mappe un item unique. Retourne null si le SIRET est absent ou vide
* (ligne inexploitable : pas de cle naturelle pour l'upsert).
*
* @param array<string, mixed> $item
*
* @return null|array<string, mixed>
*/
public static function mapOne(array $item): ?array
{
$siret = self::normalizeSiret(self::str($item['Siret'] ?? null));
if (null === $siret) {
return null;
}
return [
'siret' => $siret,
// Nom et Societe sont identiques a la source : une seule colonne.
'name' => self::str($item['Nom'] ?? null) ?? '',
'address' => self::str($item['Adresse'] ?? null),
'postal_code' => self::str($item['CodePostal'] ?? null),
'city' => self::str($item['Ville'] ?? null),
'phone' => self::str($item['Telephone_1'] ?? null),
'department' => self::str($item['Departement'] ?? null),
// Statut conserve brut (feed externe, valeurs non contraintes).
'status' => self::str($item['Statut'] ?? null) ?? '',
'validity_date' => self::parseDate(self::str($item['Validite'] ?? null)),
];
}
/**
* Normalise un SIRET : ne conserve que les chiffres. Null si vide.
* La source est "sale" (longueurs variables 7 a 14) : aucune contrainte
* de longueur, on stocke les chiffres tels quels.
*/
public static function normalizeSiret(?string $raw): ?string
{
if (null === $raw) {
return null;
}
$digits = preg_replace('/\D+/', '', $raw) ?? '';
return '' === $digits ? null : $digits;
}
/**
* Convertit une date "dd/mm/yyyy" en "yyyy-mm-dd". Null si le format ne
* correspond pas ou si la date n'est pas un jour calendaire valide
* (garde-fou : evite un INSERT en erreur sur une date impossible).
*/
public static function parseDate(?string $raw): ?string
{
if (null === $raw || !preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $raw, $m)) {
return null;
}
$day = (int) $m[1];
$month = (int) $m[2];
$year = (int) $m[3];
if (!checkdate($month, $day, $year)) {
return null;
}
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
/**
* Trim d'une valeur scalaire ; null si la chaine resultante est vide.
*/
private static function str(mixed $value): ?string
{
if (null === $value) {
return null;
}
$trimmed = trim((string) $value);
return '' === $trimmed ? null : $trimmed;
}
}
@@ -1,412 +0,0 @@
<?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;
}
}
@@ -1,154 +0,0 @@
<?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;
}
}
@@ -1,169 +0,0 @@
<?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;
}
}
@@ -1,284 +0,0 @@
<?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;
}
}
@@ -1,174 +0,0 @@
<?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;
}
}
@@ -1,33 +0,0 @@
<?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;
}
@@ -1,137 +0,0 @@
<?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;
}
}

Some files were not shown because too many files have changed in this diff Show More