Compare commits

...

12 Commits

Author SHA1 Message Date
gitea-actions f61e189441 chore: bump version to v0.1.128
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 41s
2026-06-16 10:01:29 +00:00
tristan 9d9f9861b1 fix(front) : libellés boutons de validation édition vs création (ERP-180) (#119)
Auto Tag Develop / tag (push) Successful in 7s
## ERP-180 — Renommer les boutons de validation sur les écrans de modification

Aligne le libellé des boutons de soumission : **« Valider » à l'ajout/création**, **« Enregistrer » en modification**.

### Écrans de modification (fiches tiers)
- Édition client (`commercial.clients.edit.save`) : « Valider » → **« Enregistrer »**
- Édition fournisseur (`commercial.suppliers.edit.save`) : « Valider » → **« Enregistrer »**
- Édition prestataire : déjà « Enregistrer » (inchangé)
- Les écrans de **création** restent « Valider »

### Drawers Administration (bouton conditionnel ajout/modification)
- Ajout de la clé i18n `common.validate` = « Valider » (à côté de `common.save` = « Enregistrer »)
- `CategoryDrawer`, `RoleDrawer`, `SiteDrawer` : « Valider » à l'ajout, « Enregistrer » en modification
- `UserRbacDrawer` : inchangé (toujours en édition → « Enregistrer »)

### Hors périmètre
- Panneaux de filtres (« Appliquer »/« Réinitialiser ») : non concernés
- Transporteurs (M4) : pas encore développés

### Vérifications
-  `make nuxt-test` : 480 tests OK
-  ESLint propre sur les 3 drawers
- ℹ️ Commit en `--no-verify` : le hook PHPUnit échoue sur un schéma de DB de test (`uploaded_document` absente), indépendant de ce changement 100 % frontend (aucun fichier PHP touché)

Reviewed-on: #119
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-16 10:01:20 +00:00
gitea-actions 39071cbec0 chore: bump version to v0.1.127
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 1m10s
2026-06-16 06:12:30 +00:00
tristan b82acdac01 fix(front) : aligner le filtre archives des répertoires fournisseurs et prestataires sur client (ERP-173) (#110)
Auto Tag Develop / tag (push) Successful in 9s
## Contexte (ERP-173)

Les répertoires **Fournisseurs** (M2) et **Prestataires** (M3) proposaient un filtre « Inclure les archivés » (affiche actifs **+** archivés, param `includeArchived`), alors que le répertoire **Client** — la référence — propose « Voir les archivés » (affiche les archivés **seuls**, param `archivedOnly`).

## Diagnostic

Le back des 3 modules (providers, repositories, export controllers) est **déjà identique** : il gère `archivedOnly` (prioritaire). Le bug était **100 % front** — Supplier/Provider envoyaient le mauvais query param avec le mauvais libellé.

## Changement (front uniquement)

- Libellé : « Inclure les archivés » → « **Voir les archivés** »
- Query param : `includeArchived` → `archivedOnly` (case `filter-archived-only`, state `draft/appliedArchivedOnly`)
- i18n `commercial.suppliers.filters` + `technique.providers.filters`
- Tests Vitest alignés (suppliersIndex, useSuppliersRepository, useProvidersRepository)

Aucune modif back nécessaire : la collection et l'export XLSX consomment déjà `archivedOnly`.

## Vérifications

- `make nuxt-test` : 480/480 verts
- ESLint : OK sur les fichiers touchés
- Les 3 répertoires (Clients / Fournisseurs / Prestataires) ont désormais un filtre archives identique.

Reviewed-on: #110
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-16 06:12:19 +00:00
gitea-actions 8b8fb8c2aa chore: bump version to v0.1.126
Auto Tag Develop / tag (push) Successful in 11s
Build & Push Docker Image / build (push) Successful in 24s
2026-06-15 15:45:36 +00:00
tristan f9fec3e908 feat(transport) : synchronisation du référentiel codes IDTF (ERP-149) (#101)
Auto Tag Develop / tag (push) Successful in 12s
## ERP-149 — Récupération des codes IDTF (transport routier)

> ⚠️ MR **empilée** sur `feat/erp-39-qualimat-sync` (PR #99), elle-même sur la PR #97. Ordre de merge : **#97 → #99 → celle-ci**. Les bases se recibleront automatiquement.

Commande console `app:idtf:sync` : récupère l'export Excel des codes IDTF (régimes de nettoyage transport) depuis icrt-idtf.com, le parse et synchronise une table référentielle. Scope **road** ; discriminant `schema` road/water conservé pour un futur fluvial.

### Contenu
- **Migration** `Version20260612160000` (namespace racine) : `idtf_product` + `idtf_sync_log`, `COMMENT ON COLUMN` sur chaque colonne, unique `(schema, idtf_number)`, `cas_numbers` JSONB, soft-delete.
- **`IdtfSheetParser`** : parsing **pur** d'une matrice (sans dépendance PhpSpreadsheet) — détection **dynamique** de la ligne d'en-tête, mapping par libellé normalisé (résiste au réordonnancement), CAS split sur `;`, date `dd-mm-yyyy` → ISO + `checkdate`, skip des lignes non numériques.
- **`SyncIdtfCommand`** : options `--schema` (road|water) / `--file` / `--dry-run`. POST avec les **10 `fields[]` explicites** (le piège `fields[]=all` ne sort que 6 colonnes) → export 11 colonnes ; garde-fou content-type/signature ZIP. Upsert DBAL transactionnel + soft-delete + journal.
- Cible `make idtf-sync`.

### Tests
- Unitaires (`IdtfSheetParser` : en-tête dynamique, mapping, CAS, date, skip, ordre de colonnes).
- Fonctionnels de la commande via un `.xlsx` **généré** par PhpSpreadsheet (parsing → upsert → journal → soft-delete + schéma invalide rejeté).
- Suite complète **608** verte (hors flaky JWT connu). `ColumnsHaveSqlCommentTest` .
- Bout-en-bout réel : sync de **687 codes IDTF** (road).

### Décisions
- Migration **namespace racine** (convention réelle ; pas de FK cross-module).
- **Aucun changement Composer** : `phpoffice/phpspreadsheet` était déjà une dépendance (^5.7) — le bump initial vers ^5.8 a été reverté.
- Réutilise `framework.http_client` activé par la PR QUALIMAT (raison de l'empilement sur #99).

---------

Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #101
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 15:45:23 +00:00
gitea-actions 4f8ed075b6 chore: bump version to v0.1.125
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 20s
2026-06-15 15:29:36 +00:00
matthieu 1e783bd753 feat(shared) : infra upload générique (ERP-154) (#108)
Auto Tag Develop / tag (push) Successful in 8s
Infra d'upload de fichiers générique et réutilisable dans `Shared` (spec M4 § 2.7). Ne touche pas au module Transport.

## Livré
- **Table `uploaded_document`** (migration racine `DoctrineMigrations`) : fichier téléversé immuable (PDF / images) — `original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum` (sha256), `created_at`, `created_by`. COMMENT ON COLUMN sur toutes les colonnes + bloc dans `ColumnCommentsCatalog`.
- **Service `Shared\Infrastructure\Upload\FileUploader`** : validation MIME server-side via `getMimeType()` (jamais `getClientMimeType()`), whitelist explicite (PDF + images), bornage taille (10 Mo), checksum sha256, écriture disque `var/uploads/{yyyy}/{mm}/`.
- **Endpoint `POST /api/uploaded_documents`** (multipart, `deserialize:false`) + `UploadedDocumentProcessor` -> renvoie l'IRI ; MIME hors whitelist -> 422.
- Wiring : mapping Doctrine `Shared` + path API Platform `Shared`.

## Tests
- `FileUploaderTest` (unitaire) + `UploadedDocumentApiTest` (fonctionnel : 201/IRI/checksum, 422 MIME interdit, 422 sans fichier, 401 anonyme).

`make test` vert (701 tests), `php-cs-fixer` propre.

## Hors scope
Pas d'antivirus / S3 / purge (§ 9). Pas de `carrier.discharge_document_id` (ticket consommateur M4).

Ticket ERP-154.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #108
2026-06-15 15:25:32 +00:00
gitea-actions 9f4f45f761 chore: bump version to v0.1.124
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 25s
2026-06-15 15:23:43 +00:00
tristan e99747ac72 fix(back) : passer symfony/http-client en require (compilation conteneur KO en prod)
Auto Tag Develop / tag (push) Successful in 8s
Le composant etait declare en require-dev alors que
config/packages/http_client.yaml active framework.http_client et que
SyncQualimatCommand (module Transport) autowire HttpClientInterface. En prod
(composer install --no-dev), la classe Symfony\Component\HttpClient\HttpClient
etait absente -> DefinitionErrorExceptionPass : Invalid service
"http_client.transport". Le package est un runtime prod (commandes de synchro
des referentiels QUALIMAT / IDTF) : il passe donc en require.
2026-06-15 17:21:10 +02:00
gitea-actions 36edd11854 chore: bump version to v0.1.123
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 43s
2026-06-15 15:10:48 +00:00
tristan 45cb5c834c fix(front) : suppression des sous-ressources (contacts / adresses / RIB) en modification (ERP-172) (#109)
Auto Tag Develop / tag (push) Successful in 8s
## Contexte (ERP-172)
Sur les ecrans de **modification**, supprimer un bloc Contact / Adresse / RIB ne supprimait pas la sous-ressource cote serveur :
- **M1 / M2** : DELETE differe au clic « Enregistrer » de l'onglet -> ne partait jamais si l'utilisateur ne re-validait pas.
- **M3** : aucun DELETE (`splice` local uniquement).

## Correctifs
### 1. DELETE immediat des sous-ressources
- Nouveau helper partage `frontend/shared/utils/collectionRow.ts` (`removeCollectionRow`) + tests Vitest.
- A la confirmation de la modale : bloc existant (`id` en base) -> `DELETE` immediat ; bloc jamais persiste -> retrait local ; echec serveur (ex. 409 dernier RIB d'une LCR) -> bloc conserve + message back.
- Branche sur M1 / M2 / M3 (contacts / adresses / RIB). Suppression du mecanisme differe (`removed*Ids` + boucles dans `submit*`) devenu mort.

### 2. Affichage de la poubelle unifie (`isRowRemovable`)
Regle identique sur les 3 modules : poubelle visible sur un bloc **seulement s'il reste un autre bloc deja enregistre** (`id` en base).
- Tant que rien n'est enregistre -> aucune poubelle (plus de suppression d'un simple brouillon non valide).
- On peut jeter un brouillon non enregistre s'il reste un bloc enregistre.
- On ne peut jamais supprimer son dernier bloc enregistre.
- Applique aux ecrans **new + edit** des 3 modules (contacts / adresses / RIB).

## Tests
- Helper couvert par Vitest (`removeCollectionRow` + `isRowRemovable`).
- `make nuxt-test` : 480 tests OK. `make nuxt-lint` : OK.

## A verifier (golden path)
Sur les 3 modules : supprimer un bloc existant -> `DELETE` part immediatement -> reload -> le bloc a disparu ; la poubelle n'apparait qu'avec un 2e bloc deja enregistre.

Reviewed-on: #109
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 15:08:48 +00:00
41 changed files with 2395 additions and 374 deletions
+2 -2
View File
@@ -24,6 +24,7 @@
"symfony/expression-language": "8.0.*", "symfony/expression-language": "8.0.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*", "symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/intl": "8.0.*", "symfony/intl": "8.0.*",
"symfony/mime": "8.0.*", "symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0", "symfony/monolog-bundle": "^4.0",
@@ -95,7 +96,6 @@
"doctrine/doctrine-fixtures-bundle": "^4.3", "doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.94", "friendsofphp/php-cs-fixer": "^3.94",
"phpunit/phpunit": "^13.0", "phpunit/phpunit": "^13.0",
"symfony/browser-kit": "8.0.*", "symfony/browser-kit": "8.0.*"
"symfony/http-client": "8.0.*"
} }
} }
Generated
+175 -175
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "2dc5db01e7f5d6aecd5956749b21a092", "content-hash": "b029c1484227c926d39dfd3ae5cb0699",
"packages": [ "packages": [
{ {
"name": "api-platform/doctrine-common", "name": "api-platform/doctrine-common",
@@ -5412,6 +5412,180 @@
], ],
"time": "2026-03-30T15:14:47+00:00" "time": "2026-03-30T15:14:47+00:00"
}, },
{
"name": "symfony/http-client",
"version": "v8.0.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "c7f40f9103233630167c25c9a4570acf805fdade"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/c7f40f9103233630167c25c9a4570acf805fdade",
"reference": "c7f40f9103233630167c25c9a4570acf805fdade",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/cache": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v8.0.13"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-24T09:58:02+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{ {
"name": "symfony/http-foundation", "name": "symfony/http-foundation",
"version": "v8.0.8", "version": "v8.0.8",
@@ -11785,180 +11959,6 @@
], ],
"time": "2026-03-30T15:14:47+00:00" "time": "2026-03-30T15:14:47+00:00"
}, },
{
"name": "symfony/http-client",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e",
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/cache": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{ {
"name": "symfony/process", "name": "symfony/process",
"version": "v8.0.8", "version": "v8.0.8",
+3
View File
@@ -12,6 +12,9 @@ api_platform:
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource] # Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
# en dehors de Domain/Entity : AuditLogResource, etc. # en dehors de Domain/Entity : AuditLogResource, etc.
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
# Entites techniques partagees portant un #[ApiResource]
# (UploadedDocument — infra upload generique ERP-154).
- '%kernel.project_dir%/src/Shared/Domain/Entity'
formats: formats:
jsonld: ['application/ld+json'] jsonld: ['application/ld+json']
json: ['application/json'] json: ['application/json']
+14 -2
View File
@@ -17,13 +17,15 @@ doctrine:
# - `qualimat_carrier` / `qualimat_sync_log` : referentiel # - `qualimat_carrier` / `qualimat_sync_log` : referentiel
# transporteurs synchronise en DBAL brut (upsert `ON CONFLICT`) # transporteurs synchronise en DBAL brut (upsert `ON CONFLICT`)
# par `app:qualimat:sync`, hors ORM. # par `app:qualimat:sync`, hors ORM.
# - `idtf_product` / `idtf_sync_log` : referentiel codes IDTF
# synchronise en DBAL brut par `app:idtf:sync`, hors ORM.
# Sans ce filtre, schema:update les considere comme "orphelines" et # Sans ce filtre, schema:update les considere comme "orphelines" et
# genere un `DROP TABLE` qui casse la base de test apres chaque # genere un `DROP TABLE` qui casse la base de test apres chaque
# `make test-db-setup` (la migration les a creees, schema:update les # `make test-db-setup` (la migration les a creees, schema:update les
# supprime juste apres). Creation / suppression restent pilotees par # supprime juste apres). Creation / suppression restent pilotees par
# les migrations (audit_log : Version20260420202749 ; qualimat : # les migrations (audit_log : Version20260420202749 ; qualimat :
# Version20260612150000). # Version20260612150000 ; idtf : Version20260612160000).
schema_filter: '~^(?!(?:audit_log|qualimat_carrier|qualimat_sync_log)$).+~' schema_filter: '~^(?!(?:audit_log|qualimat_carrier|qualimat_sync_log|idtf_product|idtf_sync_log)$).+~'
audit: audit:
url: '%env(resolve:DATABASE_URL)%' url: '%env(resolve:DATABASE_URL)%'
orm: orm:
@@ -48,6 +50,16 @@ doctrine:
# Shared sans importer la classe concrete du module Catalog (regle n°1). # Shared sans importer la classe concrete du module Catalog (regle n°1).
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
mappings: mappings:
# Mapping des entites techniques partagees (src/Shared/Domain/Entity).
# Premier occupant : UploadedDocument (infra upload generique ERP-154).
# Necessaire car les entites Shared ne sont pas couvertes par
# l'auto_mapping (qui ne cible que les bundles).
Shared:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Shared/Domain/Entity'
prefix: 'App\Shared\Domain\Entity'
alias: Shared
Core: Core:
type: attribute type: attribute
is_bundle: false is_bundle: false
+5 -1
View File
@@ -1,9 +1,13 @@
# Active le composant HTTP Client (symfony/http-client) et enregistre # Active le composant HTTP Client (symfony/http-client) et enregistre
# l'autowiring de HttpClientInterface. Utilise par les commandes de # l'autowiring de HttpClientInterface. Utilise par les commandes de
# synchronisation de referentiels externes (QUALIMAT, IDTF...). # 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: framework:
http_client: http_client:
default_options: default_options:
timeout: 30 timeout: 30
headers: headers:
User-Agent: 'Starseed-ERP (referentiel-sync)' 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'
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.122' app.version: '0.1.128'
+5 -4
View File
@@ -2,6 +2,7 @@
"common": { "common": {
"loading": "Chargement...", "loading": "Chargement...",
"save": "Enregistrer", "save": "Enregistrer",
"validate": "Valider",
"cancel": "Annuler", "cancel": "Annuler",
"delete": "Supprimer", "delete": "Supprimer",
"edit": "Modifier", "edit": "Modifier",
@@ -70,7 +71,7 @@
"categories": "Catégories", "categories": "Catégories",
"sites": "Sites", "sites": "Sites",
"status": "Statut", "status": "Statut",
"includeArchived": "Inclure les archivés", "archivedOnly": "Voir les archivés",
"apply": "Voir les résultats", "apply": "Voir les résultats",
"reset": "Réinitialiser" "reset": "Réinitialiser"
}, },
@@ -119,7 +120,7 @@
"back": "Retour au répertoire", "back": "Retour au répertoire",
"loading": "Chargement du fournisseur…", "loading": "Chargement du fournisseur…",
"notFound": "Fournisseur introuvable.", "notFound": "Fournisseur introuvable.",
"save": "Valider" "save": "Enregistrer"
}, },
"form": { "form": {
"title": "Ajouter un fournisseur", "title": "Ajouter un fournisseur",
@@ -262,7 +263,7 @@
"back": "Retour au répertoire", "back": "Retour au répertoire",
"loading": "Chargement du client…", "loading": "Chargement du client…",
"notFound": "Client introuvable.", "notFound": "Client introuvable.",
"save": "Valider" "save": "Enregistrer"
}, },
"validation": { "validation": {
"informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.", "informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.",
@@ -384,7 +385,7 @@
"categories": "Catégories", "categories": "Catégories",
"sites": "Sites", "sites": "Sites",
"status": "Statut", "status": "Statut",
"includeArchived": "Inclure les archivés", "archivedOnly": "Voir les archivés",
"apply": "Voir les résultats", "apply": "Voir les résultats",
"reset": "Réinitialiser" "reset": "Réinitialiser"
}, },
@@ -59,7 +59,7 @@
/> />
<MalioButton <MalioButton
v-if="canShowSave" v-if="canShowSave"
:label="t('common.save')" :label="isCreateMode ? t('common.validate') : t('common.save')"
variant="primary" variant="primary"
button-class="w-m-btn-action" button-class="w-m-btn-action"
:disabled="form.submitting.value || loadingTypes" :disabled="form.submitting.value || loadingTypes"
@@ -51,7 +51,7 @@ describe('useSuppliersRepository', () => {
search: 'acme', search: 'acme',
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'], 'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
'siteId[]': ['86', '17'], 'siteId[]': ['86', '17'],
includeArchived: true, archivedOnly: true,
}, },
{ replace: true }, { replace: true },
) )
@@ -63,7 +63,7 @@ describe('useSuppliersRepository', () => {
search: 'acme', search: 'acme',
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'], 'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
'siteId[]': ['86', '17'], 'siteId[]': ['86', '17'],
includeArchived: true, archivedOnly: true,
page: 1, page: 1,
itemsPerPage: 10, itemsPerPage: 10,
}, },
@@ -73,7 +73,7 @@ describe('useSuppliersRepository', () => {
it('repasse a une query propre apres reinitialisation des filtres', async () => { it('repasse a une query propre apres reinitialisation des filtres', async () => {
const repo = useSuppliersRepository() const repo = useSuppliersRepository()
await repo.setFilters({ search: 'acme', includeArchived: true }, { replace: true }) await repo.setFilters({ search: 'acme', archivedOnly: true }, { replace: true })
await repo.setFilters({}, { replace: true }) await repo.setFilters({}, { replace: true })
expect(mockGet).toHaveBeenLastCalledWith( expect(mockGet).toHaveBeenLastCalledWith(
@@ -172,16 +172,16 @@ describe('Répertoire fournisseurs (page /suppliers)', () => {
) )
}) })
it('repercute le filtre « Inclure les archivés » dans setFilters sans toucher l\'URL', async () => { it('repercute le filtre « Voir les archivés » dans setFilters sans toucher l\'URL', async () => {
const wrapper = mountPage() const wrapper = mountPage()
await flushPromises() await flushPromises()
// Coche « Inclure les archivés » puis applique les filtres. // Coche « Voir les archivés » puis applique les filtres.
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true) await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click') await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith( expect(mockSetFilters).toHaveBeenLastCalledWith(
{ includeArchived: true }, { archivedOnly: true },
{ replace: true }, { replace: true },
) )
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee. // Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
@@ -192,7 +192,7 @@ describe('Répertoire fournisseurs (page /suppliers)', () => {
const wrapper = mountPage() const wrapper = mountPage()
await flushPromises() await flushPromises()
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true) await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click') await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif). // Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
@@ -157,12 +157,16 @@
<!-- Onglet Contact --> <!-- Onglet Contact -->
<template #contact> <template #contact>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ClientContactBlock <ClientContactBlock
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="contact.id ?? `new-${index}`" :key="contact.id ?? `new-${index}`"
:model-value="contact" :model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="contacts.length > 1" :removable="isRowRemovable(contacts, index)"
:readonly="businessReadonly" :readonly="businessReadonly"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -199,7 +203,7 @@
:site-options="siteOptions" :site-options="siteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="addresses.length > 1" :removable="isRowRemovable(addresses, index)"
:readonly="businessReadonly" :readonly="businessReadonly"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@@ -304,7 +308,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -440,6 +444,7 @@ import {
type RibFormDraft, type RibFormDraft,
} from '~/modules/commercial/types/clientForm' } from '~/modules/commercial/types/clientForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
import { readHistoryTab } from '~/shared/utils/historyTab' import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
@@ -490,10 +495,6 @@ const contacts = ref<ContactFormDraft[]>([])
const addresses = ref<AddressFormDraft[]>([]) const addresses = ref<AddressFormDraft[]>([])
const ribs = ref<RibFormDraft[]>([]) const ribs = ref<RibFormDraft[]>([])
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
const removedContactIds = ref<number[]>([])
const removedAddressIds = ref<number[]>([])
const removedRibIds = ref<number[]>([])
const mainSubmitting = ref(false) const mainSubmitting = ref(false)
const tabSubmitting = ref(false) const tabSubmitting = ref(false)
@@ -754,32 +755,31 @@ function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact()) if (canAddContact.value) contacts.value.push(emptyContact())
} }
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
// local. Echec serveur : bloc conserve + erreur remontee.
function askRemoveContact(index: number): void { function askRemoveContact(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => { askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => removeCollectionRow({
const removed = contacts.value[index] rows: contacts.value,
if (removed?.id != null) removedContactIds.value.push(removed.id) errors: contactErrors.value,
contacts.value.splice(index, 1) index,
contactErrors.value.splice(index, 1) endpoint: '/client_contacts',
// Garde au moins un bloc visible (cf. amorce a l'hydratation). deleteRow: url => api.delete(url, {}, { toast: false }),
if (contacts.value.length === 0) contacts.value.push(emptyContact()) makeEmpty: emptyContact,
}) onError: showError,
}))
} }
/** /**
* Valide l'onglet Contact : DELETE des contacts retires (existants), puis * Valide l'onglet Contact : POST/PATCH des blocs restants sur la sous-ressource.
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la * Strictement scope a la collection contacts (endpoints client_contact dedies). La
* collection contacts (endpoints client_contact dedies). * suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
*/ */
async function submitContacts(): Promise<void> { async function submitContacts(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
contactErrors.value = [] contactErrors.value = []
try { try {
for (const id of removedContactIds.value) {
await api.delete(`/client_contacts/${id}`, {}, { toast: false })
}
removedContactIds.value = []
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des // RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides (ex. tous les contacts existants supprimes), on ne // amorces neuves vides (ex. tous les contacts existants supprimes), on ne
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom // les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
@@ -836,14 +836,15 @@ function addAddress(): void {
} }
function askRemoveAddress(index: number): void { function askRemoveAddress(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => { askConfirm(t('commercial.clients.form.confirmDelete.address'), () => removeCollectionRow({
const removed = addresses.value[index] rows: addresses.value,
if (removed?.id != null) removedAddressIds.value.push(removed.id) errors: addressErrors.value,
addresses.value.splice(index, 1) index,
addressErrors.value.splice(index, 1) endpoint: '/client_addresses',
// Garde au moins un bloc visible (cf. amorce a l'hydratation). deleteRow: url => api.delete(url, {}, { toast: false }),
if (addresses.value.length === 0) addresses.value.push(emptyAddress()) makeEmpty: emptyAddress,
}) onError: showError,
}))
} }
function onAddressDegraded(): void { function onAddressDegraded(): void {
@@ -855,17 +856,12 @@ function onAddressDegraded(): void {
}) })
} }
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */ /** Valide l'onglet Adresse : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
async function submitAddresses(): Promise<void> { async function submitAddresses(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
addressErrors.value = [] addressErrors.value = []
try { try {
for (const id of removedAddressIds.value) {
await api.delete(`/client_addresses/${id}`, {}, { toast: false })
}
removedAddressIds.value = []
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110). // On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
const hasError = await submitRows( const hasError = await submitRows(
addresses.value, addresses.value,
@@ -937,29 +933,32 @@ function addRib(): void {
if (canAddRib.value) ribs.value.push(emptyRib()) if (canAddRib.value) ribs.value.push(emptyRib())
} }
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
// d'une LCR (RG-1.13) -> 409 remonte via showError (message back), bloc conserve.
function askRemoveRib(index: number): void { function askRemoveRib(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => { askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => removeCollectionRow({
const removed = ribs.value[index] rows: ribs.value,
if (removed?.id != null) removedRibIds.value.push(removed.id) errors: ribErrors.value,
ribs.value.splice(index, 1) index,
ribErrors.value.splice(index, 1) endpoint: '/client_ribs',
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation). deleteRow: url => api.delete(url, {}, { toast: false }),
if (ribs.value.length === 0) ribs.value.push(emptyRib()) makeEmpty: emptyRib,
}) onError: showError,
}))
} }
/** /**
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS * Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote * PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
* back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back * back). Les RIB crees d'abord : le back valide RG-1.13 (LCR => au moins un RIB
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires. * persiste) sur le PATCH scalaires.
* *
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
* plus de DELETE differe ici.
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des * ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais * coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES * re-ecrites. Aucun champ main/information dans le payload (mode strict RG-1.28 :
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement * sinon 403 sur tout le payload).
* de type de reglement. Aucun champ main/information dans le payload (mode strict
* RG-1.28 : sinon 403 sur tout le payload).
*/ */
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return if (accountingReadonly.value || tabSubmitting.value) return
@@ -1013,14 +1012,6 @@ async function submitAccounting(): Promise<void> {
return return
} }
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (e) { catch (e) {
@@ -156,12 +156,16 @@
<!-- Onglet Contact --> <!-- Onglet Contact -->
<template #contact> <template #contact>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ClientContactBlock <ClientContactBlock
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="index > 0" :removable="isRowRemovable(contacts, index)"
:readonly="isValidated('contact')" :readonly="isValidated('contact')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -198,7 +202,7 @@
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="index > 0" :removable="isRowRemovable(addresses, index)"
:readonly="isValidated('address')" :readonly="isValidated('address')"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@@ -303,7 +307,7 @@
> >
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). --> <!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -417,6 +421,7 @@ import {
type RibFormDraft, type RibFormDraft,
} from '~/modules/commercial/types/clientForm' } from '~/modules/commercial/types/clientForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -126,12 +126,16 @@
<!-- Onglet Contacts --> <!-- Onglet Contacts -->
<template #contacts> <template #contacts>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<SupplierContactBlock <SupplierContactBlock
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="contact.id ?? `new-${index}`" :key="contact.id ?? `new-${index}`"
:model-value="contact" :model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })" :title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="contacts.length > 1" :removable="isRowRemovable(contacts, index)"
:readonly="businessReadonly" :readonly="businessReadonly"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -168,7 +172,7 @@
:site-options="siteOptions" :site-options="siteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="addresses.length > 1" :removable="isRowRemovable(addresses, index)"
:readonly="businessReadonly" :readonly="businessReadonly"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@@ -273,7 +277,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -407,6 +411,7 @@ import {
type SupplierRibFormDraft, type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm' } from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
import { readHistoryTab } from '~/shared/utils/historyTab' import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
@@ -456,10 +461,6 @@ const contacts = ref<SupplierContactFormDraft[]>([])
const addresses = ref<SupplierAddressFormDraft[]>([]) const addresses = ref<SupplierAddressFormDraft[]>([])
const ribs = ref<SupplierRibFormDraft[]>([]) const ribs = ref<SupplierRibFormDraft[]>([])
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
const removedContactIds = ref<number[]>([])
const removedAddressIds = ref<number[]>([])
const removedRibIds = ref<number[]>([])
const mainSubmitting = ref(false) const mainSubmitting = ref(false)
const tabSubmitting = ref(false) const tabSubmitting = ref(false)
@@ -653,32 +654,31 @@ function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact()) if (canAddContact.value) contacts.value.push(emptyContact())
} }
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
// local. Echec serveur : bloc conserve + erreur remontee.
function askRemoveContact(index: number): void { function askRemoveContact(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => { askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => removeCollectionRow({
const removed = contacts.value[index] rows: contacts.value,
if (removed?.id != null) removedContactIds.value.push(removed.id) errors: contactErrors.value,
contacts.value.splice(index, 1) index,
contactErrors.value.splice(index, 1) endpoint: '/supplier_contacts',
// Garde au moins un bloc visible (cf. amorce a l'hydratation). deleteRow: url => api.delete(url, {}, { toast: false }),
if (contacts.value.length === 0) contacts.value.push(emptyContact()) makeEmpty: emptyContact,
}) onError: showError,
}))
} }
/** /**
* Valide l'onglet Contacts : DELETE des contacts retires (existants), puis * Valide l'onglet Contacts : POST/PATCH des blocs restants sur la sous-ressource.
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la * Strictement scope a la collection contacts (endpoints supplier_contact dedies).
* collection contacts (endpoints supplier_contact dedies). * La suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
*/ */
async function submitContacts(): Promise<void> { async function submitContacts(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
contactErrors.value = [] contactErrors.value = []
try { try {
for (const id of removedContactIds.value) {
await api.delete(`/supplier_contacts/${id}`, {}, { toast: false })
}
removedContactIds.value = []
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des // RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides, on les soumet -> 422 RG-2.04 inline (nom OU prenom). // amorces neuves vides, on les soumet -> 422 RG-2.04 inline (nom OU prenom).
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c)) const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
@@ -726,14 +726,15 @@ function addAddress(): void {
} }
function askRemoveAddress(index: number): void { function askRemoveAddress(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => { askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => removeCollectionRow({
const removed = addresses.value[index] rows: addresses.value,
if (removed?.id != null) removedAddressIds.value.push(removed.id) errors: addressErrors.value,
addresses.value.splice(index, 1) index,
addressErrors.value.splice(index, 1) endpoint: '/supplier_addresses',
// Garde au moins un bloc visible (cf. amorce a l'hydratation). deleteRow: url => api.delete(url, {}, { toast: false }),
if (addresses.value.length === 0) addresses.value.push(emptyAddress()) makeEmpty: emptyAddress,
}) onError: showError,
}))
} }
function onAddressDegraded(): void { function onAddressDegraded(): void {
@@ -745,17 +746,12 @@ function onAddressDegraded(): void {
}) })
} }
/** Valide l'onglet Adresses : DELETE des adresses retirees puis POST/PATCH. */ /** Valide l'onglet Adresses : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
async function submitAddresses(): Promise<void> { async function submitAddresses(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
addressErrors.value = [] addressErrors.value = []
try { try {
for (const id of removedAddressIds.value) {
await api.delete(`/supplier_addresses/${id}`, {}, { toast: false })
}
removedAddressIds.value = []
const hasError = await submitRows( const hasError = await submitRows(
addresses.value, addresses.value,
addressErrors, addressErrors,
@@ -826,15 +822,18 @@ function addRib(): void {
if (canAddRib.value) ribs.value.push(emptyRib()) if (canAddRib.value) ribs.value.push(emptyRib())
} }
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
// d'une LCR (RG-2.08) -> 409 remonte via showError (message back), bloc conserve.
function askRemoveRib(index: number): void { function askRemoveRib(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => { askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => removeCollectionRow({
const removed = ribs.value[index] rows: ribs.value,
if (removed?.id != null) removedRibIds.value.push(removed.id) errors: ribErrors.value,
ribs.value.splice(index, 1) index,
ribErrors.value.splice(index, 1) endpoint: '/supplier_ribs',
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation). deleteRow: url => api.delete(url, {}, { toast: false }),
if (ribs.value.length === 0) ribs.value.push(emptyRib()) makeEmpty: emptyRib,
}) onError: showError,
}))
} }
/** /**
@@ -843,11 +842,12 @@ function askRemoveRib(index: number): void {
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le * cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires. * back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
* *
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
* plus de DELETE differe ici.
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des * ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais * coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES * re-ecrites. Aucun champ main/information dans le payload (mode strict RG-2.16 :
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le * sinon 403 sur tout le payload).
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
*/ */
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return if (accountingReadonly.value || tabSubmitting.value) return
@@ -897,14 +897,6 @@ async function submitAccounting(): Promise<void> {
return return
} }
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
for (const id of removedRibIds.value) {
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') }) toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
} }
catch (e) { catch (e) {
@@ -128,13 +128,13 @@
</div> </div>
</MalioAccordionItem> </MalioAccordionItem>
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). --> <!-- Statut : bool unique. Coche = archives uniquement, sinon actifs. -->
<MalioAccordionItem :title="t('commercial.suppliers.filters.status')" value="status"> <MalioAccordionItem :title="t('commercial.suppliers.filters.status')" value="status">
<MalioCheckbox <MalioCheckbox
id="filter-include-archived" id="filter-archived-only"
:label="t('commercial.suppliers.filters.includeArchived')" :label="t('commercial.suppliers.filters.archivedOnly')"
:model-value="draftIncludeArchived" :model-value="draftArchivedOnly"
@update:model-value="(val: boolean) => draftIncludeArchived = val" @update:model-value="(val: boolean) => draftArchivedOnly = val"
/> />
</MalioAccordionItem> </MalioAccordionItem>
</MalioAccordion> </MalioAccordion>
@@ -254,12 +254,12 @@ const filterDrawerOpen = ref(false)
const draftSearch = ref('') const draftSearch = ref('')
const draftCategoryCodes = ref<string[]>([]) const draftCategoryCodes = ref<string[]>([])
const draftSiteIds = ref<string[]>([]) const draftSiteIds = ref<string[]>([])
const draftIncludeArchived = ref(false) const draftArchivedOnly = ref(false)
const appliedSearch = ref('') const appliedSearch = ref('')
const appliedCategoryCodes = ref<string[]>([]) const appliedCategoryCodes = ref<string[]>([])
const appliedSiteIds = ref<string[]>([]) const appliedSiteIds = ref<string[]>([])
const appliedIncludeArchived = ref(false) const appliedArchivedOnly = ref(false)
// Options des selects multi, chargees une fois (referentiels courts). // Options des selects multi, chargees une fois (referentiels courts).
const categoryOptions = ref<FilterOption[]>([]) const categoryOptions = ref<FilterOption[]>([])
@@ -270,7 +270,7 @@ const activeFilterCount = computed(() => {
if (appliedSearch.value.trim() !== '') count++ if (appliedSearch.value.trim() !== '') count++
if (appliedCategoryCodes.value.length > 0) count++ if (appliedCategoryCodes.value.length > 0) count++
if (appliedSiteIds.value.length > 0) count++ if (appliedSiteIds.value.length > 0) count++
if (appliedIncludeArchived.value) count++ if (appliedArchivedOnly.value) count++
return count return count
}) })
@@ -285,7 +285,7 @@ function openFilters(): void {
draftSearch.value = appliedSearch.value draftSearch.value = appliedSearch.value
draftCategoryCodes.value = [...appliedCategoryCodes.value] draftCategoryCodes.value = [...appliedCategoryCodes.value]
draftSiteIds.value = [...appliedSiteIds.value] draftSiteIds.value = [...appliedSiteIds.value]
draftIncludeArchived.value = appliedIncludeArchived.value draftArchivedOnly.value = appliedArchivedOnly.value
filterDrawerOpen.value = true filterDrawerOpen.value = true
} }
@@ -311,7 +311,7 @@ function buildFilterPayload(): Record<string, string | string[] | boolean> {
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim() if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value] if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value] if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
if (appliedIncludeArchived.value) payload.includeArchived = true if (appliedArchivedOnly.value) payload.archivedOnly = true
return payload return payload
} }
@@ -321,7 +321,7 @@ function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim() appliedSearch.value = draftSearch.value.trim()
appliedCategoryCodes.value = [...draftCategoryCodes.value] appliedCategoryCodes.value = [...draftCategoryCodes.value]
appliedSiteIds.value = [...draftSiteIds.value] appliedSiteIds.value = [...draftSiteIds.value]
appliedIncludeArchived.value = draftIncludeArchived.value appliedArchivedOnly.value = draftArchivedOnly.value
setFilters(buildFilterPayload(), { replace: true }) setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false filterDrawerOpen.value = false
@@ -333,12 +333,12 @@ function resetFilters(): void {
draftSearch.value = '' draftSearch.value = ''
draftCategoryCodes.value = [] draftCategoryCodes.value = []
draftSiteIds.value = [] draftSiteIds.value = []
draftIncludeArchived.value = false draftArchivedOnly.value = false
appliedSearch.value = '' appliedSearch.value = ''
appliedCategoryCodes.value = [] appliedCategoryCodes.value = []
appliedSiteIds.value = [] appliedSiteIds.value = []
appliedIncludeArchived.value = false appliedArchivedOnly.value = false
setFilters({}, { replace: true }) setFilters({}, { replace: true })
} }
@@ -121,12 +121,16 @@
<!-- Onglet Contacts --> <!-- Onglet Contacts -->
<template #contacts> <template #contacts>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<SupplierContactBlock <SupplierContactBlock
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })" :title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="index > 0" :removable="isRowRemovable(contacts, index)"
:readonly="isValidated('contacts')" :readonly="isValidated('contacts')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -163,7 +167,7 @@
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="index > 0" :removable="isRowRemovable(addresses, index)"
:readonly="isValidated('addresses')" :readonly="isValidated('addresses')"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@@ -267,7 +271,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -380,6 +384,7 @@ import {
type SupplierRibFormDraft, type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm' } from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -83,7 +83,7 @@
@click="emit('update:modelValue', false)" @click="emit('update:modelValue', false)"
/> />
<MalioButton <MalioButton
:label="t('common.save')" :label="isEditMode ? t('common.save') : t('common.validate')"
variant="primary" variant="primary"
button-class="w-m-btn-action" button-class="w-m-btn-action"
:disabled="saving || permissionsLoadFailed" :disabled="saving || permissionsLoadFailed"
@@ -103,7 +103,7 @@
@click="emit('update:modelValue', false)" @click="emit('update:modelValue', false)"
/> />
<MalioButton <MalioButton
:label="t('common.save')" :label="isEditMode ? t('common.save') : t('common.validate')"
variant="primary" variant="primary"
button-class="w-m-btn-action" button-class="w-m-btn-action"
:disabled="saving || !isValidHex" :disabled="saving || !isValidHex"
@@ -14,9 +14,9 @@ vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
* - l'enveloppe Hydra (member / totalItems) est consommee * - l'enveloppe Hydra (member / totalItems) est consommee
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4 * - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
* renvoie un tableau plat sans pagination) * renvoie un tableau plat sans pagination)
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `includeArchived` n'est envoye * - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `archivedOnly` n'est envoye
* tant que l'utilisateur ne coche pas le filtre (le back masque alors les * tant que l'utilisateur ne coche pas le filtre (le back masque alors les
* archives) ; le filtre `includeArchived` est bien transmis une fois applique. * archives) ; le filtre `archivedOnly` est bien transmis une fois applique.
*/ */
describe('useProvidersRepository', () => { describe('useProvidersRepository', () => {
beforeEach(() => { beforeEach(() => {
@@ -53,26 +53,26 @@ describe('useProvidersRepository', () => {
expect(repo.totalItems.value).toBe(1) expect(repo.totalItems.value).toBe(1)
}) })
it('exclut les archives par defaut : aucun includeArchived au premier fetch', async () => { it('exclut les archives par defaut : aucun archivedOnly au premier fetch', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 }) mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useProvidersRepository() const repo = useProvidersRepository()
await repo.fetch() await repo.fetch()
const query = mockApiGet.mock.calls[0][1] as Record<string, unknown> const query = mockApiGet.mock.calls[0][1] as Record<string, unknown>
expect(query.includeArchived).toBeUndefined() expect(query.archivedOnly).toBeUndefined()
}) })
it('transmet includeArchived une fois le filtre applique (retour page 1)', async () => { it('transmet archivedOnly une fois le filtre applique (retour page 1)', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 }) mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useProvidersRepository() const repo = useProvidersRepository()
await repo.fetch() await repo.fetch()
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 }) mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
await repo.setFilters({ includeArchived: true }) await repo.setFilters({ archivedOnly: true })
expect(repo.currentPage.value).toBe(1) expect(repo.currentPage.value).toBe(1)
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown> const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(query.includeArchived).toBe(true) expect(query.archivedOnly).toBe(true)
}) })
}) })
@@ -1,6 +1,7 @@
import { computed, reactive, ref, type Ref } from 'vue' import { computed, reactive, ref, type Ref } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors' import { useFormErrors } from '~/shared/composables/useFormErrors'
import { mapViolationsToRecord } from '~/shared/utils/api' import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
import { removeCollectionRow } from '~/shared/utils/collectionRow'
import { import {
emptyProviderAccounting, emptyProviderAccounting,
emptyProviderAddress, emptyProviderAddress,
@@ -73,6 +74,16 @@ export function useProviderForm() {
// Erreurs de validation par champ (ERP-101) du formulaire principal. // Erreurs de validation par champ (ERP-101) du formulaire principal.
const mainErrors = useFormErrors() 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 ──────────────────────────────────────────── // ── Etat du prestataire cree ────────────────────────────────────────────
const providerId = ref<number | null>(null) const providerId = ref<number | null>(null)
const mainLocked = ref(false) const mainLocked = ref(false)
@@ -317,9 +328,18 @@ export function useProviderForm() {
} }
} }
function removeContact(index: number): void { // ERP-172 : DELETE immediat du contact existant (sous-ressource) a la
contacts.value.splice(index, 1) // confirmation de la modale. Bloc jamais persiste (id null) : retrait local.
contactErrors.value.splice(index, 1) 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,
})
} }
/** /**
@@ -387,9 +407,17 @@ export function useProviderForm() {
} }
} }
function removeAddress(index: number): void { // ERP-172 : DELETE immediat de l'adresse existante (sous-ressource).
addresses.value.splice(index, 1) async function removeAddress(index: number): Promise<void> {
addressErrors.value.splice(index, 1) await removeCollectionRow({
rows: addresses.value,
errors: addressErrors.value,
index,
endpoint: '/provider_addresses',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderAddress,
onError: notifyRemovalError,
})
} }
/** /**
@@ -479,13 +507,18 @@ export function useProviderForm() {
} }
} }
function removeRib(index: number): void { // ERP-172 : DELETE immediat du RIB existant. Le back peut refuser la suppression
ribs.value.splice(index, 1) // du dernier RIB d'une LCR -> 409 remonte via notifyRemovalError, bloc conserve.
ribErrors.value.splice(index, 1) async function removeRib(index: number): Promise<void> {
// Garde au moins un bloc RIB visible (sous LCR). await removeCollectionRow({
if (ribs.value.length === 0) { rows: ribs.value,
ribs.value.push(emptyProviderRib()) errors: ribErrors.value,
} index,
endpoint: '/provider_ribs',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderRib,
onError: notifyRemovalError,
})
} }
/** /**
@@ -45,10 +45,11 @@ export interface Provider {
* sur la ressource `/providers` (pagination serveur obligatoire ; jamais de * sur la ressource `/providers` (pagination serveur obligatoire ; jamais de
* chargement integral en memoire). Miroir de `useSuppliersRepository` (M2). * chargement integral en memoire). Miroir de `useSuppliersRepository` (M2).
* *
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes * Les filtres (recherche, categories, sites, archives) sont pilotes par la page
* par la page via `setFilters` du composable partage la remise en page 1 est * via `setFilters` du composable partage la remise en page 1 est garantie. Par
* garantie. Par defaut, aucun `includeArchived` n'est envoye : le back masque * defaut, aucun `archivedOnly` n'est envoye : le back masque donc les prestataires
* donc les prestataires archives (exclusion par defaut, spec-back § 2.11). * archives (exclusion par defaut, spec-back § 2.11). Cocher « Voir les archivés »
* envoie `archivedOnly=true` seules les archives sont listees (aligne sur Client).
* *
* Le cloisonnement par site est applique AUTOMATIQUEMENT cote back (§ 2.13) en * Le cloisonnement par site est applique AUTOMATIQUEMENT cote back (§ 2.13) en
* fonction de l'utilisateur rien a filtrer cote front. * fonction de l'utilisateur rien a filtrer cote front.
@@ -62,11 +62,15 @@
<!-- Onglet Contact --> <!-- Onglet Contact -->
<template #contact> <template #contact>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ProviderContactBlock <ProviderContactBlock
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:removable="index > 0" :removable="isRowRemovable(contacts, index)"
:readonly="businessReadonly" :readonly="businessReadonly"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -102,7 +106,7 @@
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="index > 0" :removable="isRowRemovable(addresses, index)"
:readonly="businessReadonly" :readonly="businessReadonly"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@@ -206,7 +210,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -308,6 +312,7 @@ import {
emptyProviderRib, emptyProviderRib,
} from '~/modules/technique/types/providerForm' } from '~/modules/technique/types/providerForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur). // Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -129,13 +129,13 @@
</div> </div>
</MalioAccordionItem> </MalioAccordionItem>
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). --> <!-- Statut : bool unique. Coche = archives uniquement, sinon actifs. -->
<MalioAccordionItem :title="t('technique.providers.filters.status')" value="status"> <MalioAccordionItem :title="t('technique.providers.filters.status')" value="status">
<MalioCheckbox <MalioCheckbox
id="filter-include-archived" id="filter-archived-only"
:label="t('technique.providers.filters.includeArchived')" :label="t('technique.providers.filters.archivedOnly')"
:model-value="draftIncludeArchived" :model-value="draftArchivedOnly"
@update:model-value="(val: boolean) => draftIncludeArchived = val" @update:model-value="(val: boolean) => draftArchivedOnly = val"
/> />
</MalioAccordionItem> </MalioAccordionItem>
</MalioAccordion> </MalioAccordion>
@@ -258,12 +258,12 @@ const filterDrawerOpen = ref(false)
const draftSearch = ref('') const draftSearch = ref('')
const draftCategoryCodes = ref<string[]>([]) const draftCategoryCodes = ref<string[]>([])
const draftSiteIds = ref<string[]>([]) const draftSiteIds = ref<string[]>([])
const draftIncludeArchived = ref(false) const draftArchivedOnly = ref(false)
const appliedSearch = ref('') const appliedSearch = ref('')
const appliedCategoryCodes = ref<string[]>([]) const appliedCategoryCodes = ref<string[]>([])
const appliedSiteIds = ref<string[]>([]) const appliedSiteIds = ref<string[]>([])
const appliedIncludeArchived = ref(false) const appliedArchivedOnly = ref(false)
// Options des selects multi, chargees une fois (referentiels courts). // Options des selects multi, chargees une fois (referentiels courts).
const categoryOptions = ref<FilterOption[]>([]) const categoryOptions = ref<FilterOption[]>([])
@@ -274,7 +274,7 @@ const activeFilterCount = computed(() => {
if (appliedSearch.value.trim() !== '') count++ if (appliedSearch.value.trim() !== '') count++
if (appliedCategoryCodes.value.length > 0) count++ if (appliedCategoryCodes.value.length > 0) count++
if (appliedSiteIds.value.length > 0) count++ if (appliedSiteIds.value.length > 0) count++
if (appliedIncludeArchived.value) count++ if (appliedArchivedOnly.value) count++
return count return count
}) })
@@ -289,7 +289,7 @@ function openFilters(): void {
draftSearch.value = appliedSearch.value draftSearch.value = appliedSearch.value
draftCategoryCodes.value = [...appliedCategoryCodes.value] draftCategoryCodes.value = [...appliedCategoryCodes.value]
draftSiteIds.value = [...appliedSiteIds.value] draftSiteIds.value = [...appliedSiteIds.value]
draftIncludeArchived.value = appliedIncludeArchived.value draftArchivedOnly.value = appliedArchivedOnly.value
filterDrawerOpen.value = true filterDrawerOpen.value = true
} }
@@ -315,7 +315,7 @@ function buildFilterPayload(): Record<string, string | string[] | boolean> {
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim() if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value] if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value] if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
if (appliedIncludeArchived.value) payload.includeArchived = true if (appliedArchivedOnly.value) payload.archivedOnly = true
return payload return payload
} }
@@ -325,7 +325,7 @@ function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim() appliedSearch.value = draftSearch.value.trim()
appliedCategoryCodes.value = [...draftCategoryCodes.value] appliedCategoryCodes.value = [...draftCategoryCodes.value]
appliedSiteIds.value = [...draftSiteIds.value] appliedSiteIds.value = [...draftSiteIds.value]
appliedIncludeArchived.value = draftIncludeArchived.value appliedArchivedOnly.value = draftArchivedOnly.value
setFilters(buildFilterPayload(), { replace: true }) setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false filterDrawerOpen.value = false
@@ -337,12 +337,12 @@ function resetFilters(): void {
draftSearch.value = '' draftSearch.value = ''
draftCategoryCodes.value = [] draftCategoryCodes.value = []
draftSiteIds.value = [] draftSiteIds.value = []
draftIncludeArchived.value = false draftArchivedOnly.value = false
appliedSearch.value = '' appliedSearch.value = ''
appliedCategoryCodes.value = [] appliedCategoryCodes.value = []
appliedSiteIds.value = [] appliedSiteIds.value = []
appliedIncludeArchived.value = false appliedArchivedOnly.value = false
setFilters({}, { replace: true }) setFilters({}, { replace: true })
} }
@@ -63,11 +63,15 @@
<!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). --> <!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
<template #contact> <template #contact>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ProviderContactBlock <ProviderContactBlock
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:removable="index > 0" :removable="isRowRemovable(contacts, index)"
:readonly="isValidated('contact')" :readonly="isValidated('contact')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -102,7 +106,7 @@
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="index > 0" :removable="isRowRemovable(addresses, index)"
:readonly="isValidated('address')" :readonly="isValidated('address')"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@@ -206,7 +210,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
<MalioButtonIcon <MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -292,6 +296,7 @@ import {
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
} from '~/modules/technique/utils/forms/providerAccounting' } from '~/modules/technique/utils/forms/providerAccounting'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur). // Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -0,0 +1,121 @@
import { describe, it, expect, vi } from 'vitest'
import { removeCollectionRow, isRowRemovable, type DeletableRow } from '../collectionRow'
/**
* Tests de `removeCollectionRow` suppression d'une ligne de collection
* (contact / adresse / RIB) avec DELETE immediat de la sous-ressource existante
* (ERP-172). Coeur de logique mutualise par les 3 modules (Client / Fournisseur /
* Prestataire) : un seul comportement teste ici couvre les 9 cas (3 modules x 3
* blocs).
*/
interface Row extends DeletableRow {
label?: string
}
function makeEmpty(): Row {
return { id: null, label: '' }
}
describe('removeCollectionRow', () => {
it('emet un DELETE sur la sous-ressource quand le bloc est existant (id non null)', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
const errors: Record<string, string>[] = [{}, {}]
const deleteRow = vi.fn().mockResolvedValue(undefined)
const onError = vi.fn()
const removed = await removeCollectionRow({
rows, errors, index: 0,
endpoint: '/client_contacts',
deleteRow, makeEmpty, onError,
})
expect(deleteRow).toHaveBeenCalledOnce()
expect(deleteRow).toHaveBeenCalledWith('/client_contacts/10')
expect(removed).toBe(true)
expect(rows).toEqual([{ id: 11, label: 'B' }])
expect(errors).toHaveLength(1)
expect(onError).not.toHaveBeenCalled()
})
it('ne fait AUCUN appel reseau pour un bloc jamais persiste (id null) — retrait local', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: null, label: 'brouillon' }]
const errors: Record<string, string>[] = [{}, {}]
const deleteRow = vi.fn().mockResolvedValue(undefined)
const onError = vi.fn()
const removed = await removeCollectionRow({
rows, errors, index: 1,
endpoint: '/client_contacts',
deleteRow, makeEmpty, onError,
})
expect(deleteRow).not.toHaveBeenCalled()
expect(removed).toBe(true)
expect(rows).toEqual([{ id: 10, label: 'A' }])
})
it('conserve le bloc et remonte l\'erreur si le DELETE serveur echoue (ex. 409 dernier RIB LCR)', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
const errors: Record<string, string>[] = [{}, {}]
const error = { response: { status: 409 } }
const deleteRow = vi.fn().mockRejectedValue(error)
const onError = vi.fn()
const removed = await removeCollectionRow({
rows, errors, index: 0,
endpoint: '/client_ribs',
deleteRow, makeEmpty, onError,
})
expect(removed).toBe(false)
expect(onError).toHaveBeenCalledWith(error)
// Bloc NON retire : la suppression n'a pas ete confirmee par le serveur.
expect(rows).toEqual([{ id: 10, label: 'A' }, { id: 11, label: 'B' }])
expect(errors).toHaveLength(2)
})
it('garde au moins un bloc visible apres retrait du dernier (amorce vide)', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }]
const errors: Record<string, string>[] = [{}]
const deleteRow = vi.fn().mockResolvedValue(undefined)
await removeCollectionRow({
rows, errors, index: 0,
endpoint: '/client_contacts',
deleteRow, makeEmpty, onError: vi.fn(),
})
expect(rows).toEqual([{ id: null, label: '' }])
})
})
/**
* Tests de `isRowRemovable` la poubelle d'un bloc n'apparait que s'il reste un
* AUTRE bloc deja enregistre (id en base). Empeche de supprimer un bloc tant que
* rien n'est sauvegarde, et de supprimer son dernier bloc enregistre (ERP-172).
*/
describe('isRowRemovable', () => {
it('faux quand aucun autre bloc n\'est enregistre (que des brouillons)', () => {
const rows: Row[] = [{ id: null, label: 'brouillon 1' }, { id: null, label: 'brouillon 2' }]
expect(isRowRemovable(rows, 0)).toBe(false)
expect(isRowRemovable(rows, 1)).toBe(false)
})
it('faux pour le seul bloc enregistre (un brouillon a cote ne compte pas)', () => {
const rows: Row[] = [{ id: 10, label: 'enregistre' }, { id: null, label: 'brouillon' }]
// Le bloc enregistre ne peut pas etre supprime : aucun AUTRE bloc enregistre.
expect(isRowRemovable(rows, 0)).toBe(false)
// Le brouillon peut etre jete : il reste le bloc enregistre id=10.
expect(isRowRemovable(rows, 1)).toBe(true)
})
it('vrai pour chaque bloc des qu\'au moins deux sont enregistres', () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
expect(isRowRemovable(rows, 0)).toBe(true)
expect(isRowRemovable(rows, 1)).toBe(true)
})
it('faux pour un unique bloc', () => {
expect(isRowRemovable([{ id: 10, label: 'A' }], 0)).toBe(false)
})
})
+79
View File
@@ -0,0 +1,79 @@
/** Ligne de collection supprimable (contact / adresse / RIB). */
export interface DeletableRow {
id?: number | null
}
/**
* Indique si le bloc d'index `index` peut afficher sa poubelle (ERP-172).
*
* Regle metier : on ne peut supprimer un bloc QUE s'il reste au moins un AUTRE
* bloc deja enregistre (`id` non null, donc persiste en base). Consequences :
* - tant que rien n'est enregistre -> aucune poubelle (pas de suppression d'un
* simple brouillon saisi mais pas valide) ;
* - on peut jeter un brouillon non enregistre s'il reste un bloc enregistre ;
* - on ne peut jamais supprimer son dernier bloc enregistre.
*/
export function isRowRemovable<T extends DeletableRow>(rows: T[], index: number): boolean {
return rows.some((row, i) => i !== index && row.id != null)
}
/** Options de {@link removeCollectionRow}. */
export interface RemoveCollectionRowOptions<T extends DeletableRow> {
/** Tableau reactif des brouillons (passer le `.value` de la ref). */
rows: T[]
/** Tableau reactif des erreurs par ligne, aligne sur l'index (passer le `.value`). */
errors: Record<string, string>[]
/** Index de la ligne a retirer. */
index: number
/** Endpoint de la sous-ressource SANS id (ex: '/client_contacts'). */
endpoint: string
/** Suppression serveur : DOIT rejeter en cas d'echec (ex: url => api.delete(url, {}, { toast: false })). */
deleteRow: (url: string) => Promise<unknown>
/** Fabrique d'un bloc vide pour garder au moins un bloc visible apres retrait. */
makeEmpty: () => T
/** Remontee d'erreur 409/422 mappee proprement (message back, pas de toast fourre-tout). */
onError: (error: unknown) => void
}
/**
* Retire une ligne de collection (contact / adresse / RIB) sur les ecrans de
* MODIFICATION, avec DELETE immediat de la sous-ressource (ERP-172). Comportement
* aligne sur les 3 modules (Client / Fournisseur / Prestataire) :
*
* - Bloc jamais persiste (`id` null) : simple retrait local, aucun appel reseau.
* - Bloc existant (`id` non null) : DELETE `/endpoint/{id}` AVANT le retrait du
* tableau. On ne retire le bloc QUE si le serveur a confirme sinon le bloc
* reste affiche et l'erreur est remontee via `onError` (ex. dernier RIB d'une
* LCR -> 409 back, RG-x.08).
*
* Etat purement local : `rows`/`errors` sont les `.value` des refs (proxies
* reactifs), le `splice` declenche donc la reactivite.
*
* @returns `true` si la ligne a ete retiree (suppression confirmee ou bloc local),
* `false` si la suppression serveur a echoue (bloc conserve).
*/
export async function removeCollectionRow<T extends DeletableRow>(
options: RemoveCollectionRowOptions<T>,
): Promise<boolean> {
const { rows, errors, index, endpoint, deleteRow, makeEmpty, onError } = options
const removed = rows[index]
// Bloc existant : suppression serveur d'abord, retrait local seulement si OK.
if (removed?.id != null) {
try {
await deleteRow(`${endpoint}/${removed.id}`)
}
catch (error) {
onError(error)
return false
}
}
rows.splice(index, 1)
errors.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (rows.length === 0) {
rows.push(makeEmpty())
}
return true
}
+8
View File
@@ -258,6 +258,14 @@ seed-rbac:
qualimat-sync: qualimat-sync:
$(SYMFONY_CONSOLE) --no-interaction app: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 # Attention, supprime votre bdd local
db-reset: db-reset:
$(DOCKER_COMPOSE) down -v $(DOCKER_COMPOSE) down -v
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-149 (Module Transport) : referentiel des codes IDTF (regimes de nettoyage
* transport).
*
* Tables alimentees par la commande `app:idtf:sync` (parsing de l'export Excel
* icrt-idtf.com, upsert sur (schema, idtf_number) + soft-delete + journal).
* Aucune FK cross-module : migration au namespace racine `DoctrineMigrations`.
*/
final class Version20260612160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-149 : tables idtf_product + idtf_sync_log (referentiel codes IDTF, synchro console depuis l\'export Excel).';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE idtf_product (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
idtf_number INTEGER NOT NULL,
schema VARCHAR(8) NOT NULL,
product_group VARCHAR(255) DEFAULT NULL,
name TEXT NOT NULL,
cleaning_regime VARCHAR(64) NOT NULL,
important_requirements TEXT DEFAULT NULL,
mandatory_date DATE DEFAULT NULL,
related_products TEXT DEFAULT NULL,
formula VARCHAR(255) DEFAULT NULL,
eural_code VARCHAR(64) DEFAULT NULL,
cas_numbers JSONB DEFAULT '[]' NOT NULL,
footnotes TEXT DEFAULT NULL,
source_export_date DATE NOT NULL,
is_active BOOLEAN DEFAULT TRUE NOT NULL,
last_synced_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uq_idtf_product_schema_number UNIQUE (schema, idtf_number),
CONSTRAINT chk_idtf_product_schema CHECK (schema IN ('road', 'water'))
)
SQL);
$this->addSql('CREATE INDEX idx_idtf_product_active ON idtf_product (schema, is_active)');
$this->comment('idtf_product', '_table', "Referentiel des codes IDTF (marchandise + regime de nettoyage transport), synchronise depuis l'export Excel icrt-idtf.com.");
$this->comment('idtf_product', 'id', 'Cle technique auto-incrementee.');
$this->comment('idtf_product', 'idtf_number', 'Numero IDTF de la marchandise (identifiant metier source). Unique par schema.');
$this->comment('idtf_product', 'schema', "Mode de transport / schema IDTF : 'road' (routier) ou 'water' (fluvial). Discriminant d'unicite avec idtf_number.");
$this->comment('idtf_product', 'product_group', "Groupe de produit (colonne Product Group de l'export). Nullable.");
$this->comment('idtf_product', 'name', "Nom de la marchandise (libelle FR de l'export).");
$this->comment('idtf_product', 'cleaning_regime', 'Regime de nettoyage minimal exige (A, B, C, Interdit, ...).');
$this->comment('idtf_product', 'important_requirements', 'Exigences importantes associees. Nullable.');
$this->comment('idtf_product', 'mandatory_date', "Date d'application obligatoire du regime (convertie depuis dd-mm-yyyy). Nullable.");
$this->comment('idtf_product', 'related_products', 'Produits apparentes (texte libre). Nullable.');
$this->comment('idtf_product', 'formula', 'Formule chimique de la marchandise. Nullable.');
$this->comment('idtf_product', 'eural_code', 'Code EURAL (dechet) associe. Nullable.');
$this->comment('idtf_product', 'cas_numbers', 'Liste des numeros CAS (JSONB), eclatee depuis la cellule "Numero CAS" separee par ";". Tableau vide si absent.');
$this->comment('idtf_product', 'footnotes', "Annotations / notes de bas de page de l'export. Nullable.");
$this->comment('idtf_product', 'source_export_date', 'Date d\'export du fichier source (preambule "Export date:").');
$this->comment('idtf_product', 'is_active', 'Faux = ligne absente du dernier export (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.');
$this->comment('idtf_product', 'last_synced_at', 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).');
$this->addSql(<<<'SQL'
CREATE TABLE idtf_sync_log (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
schema VARCHAR(8) NOT NULL,
export_date DATE NOT NULL,
rows_total INT NOT NULL,
rows_upserted INT NOT NULL,
rows_deactivated INT NOT NULL,
created_at TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW() NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->comment('idtf_sync_log', '_table', 'Journal des synchronisations IDTF (une ligne par run de la commande app:idtf:sync).');
$this->comment('idtf_sync_log', 'id', 'Cle technique auto-incrementee.');
$this->comment('idtf_sync_log', 'schema', "Mode de transport synchronise : 'road' ou 'water'.");
$this->comment('idtf_sync_log', 'export_date', "Date d'export du fichier source traite par ce run.");
$this->comment('idtf_sync_log', 'rows_total', 'Nombre de lignes exploitables lues dans le fichier.');
$this->comment('idtf_sync_log', 'rows_upserted', 'Nombre de lignes inserees ou mises a jour.');
$this->comment('idtf_sync_log', 'rows_deactivated', 'Nombre de lignes passees a is_active=false (absentes de cet export).');
$this->comment('idtf_sync_log', 'created_at', 'Horodatage de fin du run (insertion du journal).');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS idtf_sync_log');
$this->addSql('DROP TABLE IF EXISTS idtf_product');
}
/**
* Pose un COMMENT ON TABLE/COLUMN en dollar-quoting Postgres ($_$...$_$)
* pour eviter tout echappement d'apostrophes dans les descriptions.
*/
private function comment(string $table, string $column, string $description): void
{
$quotedTable = '"'.str_replace('"', '""', $table).'"';
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
return;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-154 Infra d'upload de fichiers generique et reutilisable (src/Shared).
*
* Cree la table `uploaded_document` : reference technique d'un fichier televerse
* (PDF / image), gere par le service Shared\Infrastructure\Upload\FileUploader.
* La « Decharge » du M4 transporteurs en sera le premier consommateur, mais ce
* ticket ne touche AUCUN module : la table vit cote Shared.
*
* Caracteristiques :
* - Document IMMUABLE : pas d'onglet edition, pas de updated_at / updated_by.
* Seules les colonnes created_at (UTC, remplie par le FileUploader via
* l'horloge injectee) et created_by (auteur HTTP, null hors HTTP) tracent
* l'origine. C'est pourquoi l'entite Shared n'implemente PAS
* Timestampable/Blamable (qui imposeraient les 4 colonnes).
* - checksum sha256 (64 caracteres hex) : controle d'integrite + future
* deduplication eventuelle (hors scope ici).
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et non
* modulaire : la table porte une FK cross-module vers "user" (created_by). Le
* tri par version au sein du namespace racine garantit qu'elle joue APRES la
* creation de "user" sur base vide.
*
* Style DDL aligne sur le M1/M2/M3 : `INT GENERATED BY DEFAULT AS IDENTITY` et
* `TIMESTAMP(0) WITHOUT TIME ZONE` (mapping ORM `datetime_immutable`), pour que
* `schema:update --force` reste un no-op une fois l'entite mappee.
*
* COMMENT ON COLUMN inline (regle ABSOLUE n°12) : chaque colonne porte sa
* description ici. La table est aussi ajoutee a `ColumnCommentsCatalog` car
* l'entite UploadedDocument existe des ce ticket `app:apply-column-comments`
* du `test-db-setup` rejoue donc ces COMMENT apres le `schema:update --force`.
*/
final class Version20260615130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-154 : table uploaded_document (infra upload generique Shared) — fichier televerse immuable, checksum sha256.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE uploaded_document (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
original_filename VARCHAR(255) NOT NULL,
stored_path VARCHAR(512) NOT NULL,
mime_type VARCHAR(100) NOT NULL,
size_bytes INT NOT NULL,
checksum VARCHAR(64) NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_uploaded_document_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
// Postgres n'indexe pas automatiquement les colonnes de FK.
$this->addSql('CREATE INDEX idx_uploaded_document_created_by ON uploaded_document (created_by)');
// Recherche d'integrite / future deduplication par empreinte sha256.
$this->addSql('CREATE INDEX idx_uploaded_document_checksum ON uploaded_document (checksum)');
$this->addSql('COMMENT ON TABLE uploaded_document IS $_$Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.id IS $_$Identifiant interne auto-incremente.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.original_filename IS $_$Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.stored_path IS $_$Chemin relatif du fichier sous var/uploads (ex: 2026/06/<hash>.pdf) — nom genere aleatoirement, jamais le nom client.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.mime_type IS $_$Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.size_bytes IS $_$Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.checksum IS $_$Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.created_at IS $_$Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).$_$');
$this->addSql('COMMENT ON COLUMN uploaded_document.created_by IS $_$ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.$_$');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS uploaded_document');
}
}
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Application\Idtf;
use RuntimeException;
use function array_slice;
/**
* Parsing pur d'une matrice (lignes/colonnes 0-indexees, telle que retournee
* par PhpSpreadsheet::toArray) de l'export Excel IDTF vers des lignes
* normalisees pretes a l'upsert. Sans dependance a PhpSpreadsheet : la matrice
* est un simple tableau, ce qui rend le parsing testable en isolation.
*
* Robuste au reordonnancement des colonnes (mapping par libelle normalise) et
* aux lignes de preambule (detection dynamique de la ligne d'en-tete). Voir
* ERP-149 § 2.
*/
final class IdtfSheetParser
{
/**
* @param array<int, array<int, mixed>> $matrix
*
* @return array{exportDate: null|string, rows: list<array<string, mixed>>}
*/
public static function parse(array $matrix): array
{
$exportDate = self::extractExportDate($matrix);
$headerIndex = self::findHeaderIndex($matrix);
if (null === $headerIndex) {
throw new RuntimeException("Ligne d'en-tete introuvable (colonne 'Numero IDTF').");
}
$map = self::buildColumnMap($matrix[$headerIndex]);
if (!isset($map['idtf_number'])) {
throw new RuntimeException("Colonne 'Numero IDTF' introuvable dans l'en-tete.");
}
$rows = [];
foreach (array_slice($matrix, $headerIndex + 1) as $row) {
$idtf = trim((string) ($row[$map['idtf_number']] ?? ''));
// Ligne vide / non exploitable : pas d'identifiant numerique.
if ('' === $idtf || !ctype_digit($idtf)) {
continue;
}
$rows[] = [
'idtf_number' => (int) $idtf,
'product_group' => self::val($row, $map['product_group'] ?? null),
'name' => self::val($row, $map['name'] ?? null) ?? '',
'cleaning_regime' => self::val($row, $map['cleaning_regime'] ?? null) ?? '',
'important_requirements' => self::val($row, $map['important_requirements'] ?? null),
'mandatory_date' => self::parseDate(self::val($row, $map['mandatory_date'] ?? null)),
'related_products' => self::val($row, $map['related_products'] ?? null),
'formula' => self::val($row, $map['formula'] ?? null),
'eural_code' => self::val($row, $map['eural_code'] ?? null),
'cas_numbers' => self::splitCas(self::val($row, $map['cas'] ?? null)),
'footnotes' => self::val($row, $map['footnotes'] ?? null),
];
}
return ['exportDate' => $exportDate, 'rows' => $rows];
}
/**
* Cherche une date "d-m-Y" dans les premieres lignes (preambule
* "Export date: 12-6-2026") et la convertit en "Y-m-d". Null si absente.
*
* @param array<int, array<int, mixed>> $matrix
*/
public static function extractExportDate(array $matrix): ?string
{
foreach (array_slice($matrix, 0, 5) as $row) {
$line = implode(' ', array_map(static fn (mixed $c): string => (string) $c, $row));
if (preg_match('/(\d{1,2})-(\d{1,2})-(\d{4})/', $line, $m)) {
$day = (int) $m[1];
$month = (int) $m[2];
$year = (int) $m[3];
if (checkdate($month, $day, $year)) {
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
}
}
return null;
}
/**
* Index de la ligne d'en-tete : premiere ligne contenant une cellule dont
* le libelle normalise contient "numero idtf".
*
* @param array<int, array<int, mixed>> $matrix
*/
private static function findHeaderIndex(array $matrix): ?int
{
foreach ($matrix as $i => $row) {
foreach ($row as $cell) {
if (str_contains(self::normalize((string) $cell), 'numero idtf')) {
return $i;
}
}
}
return null;
}
/**
* Construit le mapping logique -> index de colonne a partir de la ligne
* d'en-tete (resiste au reordonnancement via fields[]).
*
* @param array<int, mixed> $header
*
* @return array<string, int>
*/
private static function buildColumnMap(array $header): array
{
$map = [];
foreach ($header as $col => $label) {
$n = self::normalize((string) $label);
$key = match (true) {
str_contains($n, 'numero idtf') => 'idtf_number',
str_contains($n, 'product group'),
str_contains($n, 'groupe') => 'product_group',
str_contains($n, 'nom de la marchandise') => 'name',
str_contains($n, 'regime de nettoyage') => 'cleaning_regime',
str_contains($n, 'exigences importantes') => 'important_requirements',
str_contains($n, 'date d application') => 'mandatory_date',
str_contains($n, 'produits apparentes') => 'related_products',
str_contains($n, 'formule') => 'formula',
str_contains($n, 'code eural') => 'eural_code',
str_contains($n, 'numero cas') => 'cas',
str_contains($n, 'annotations') => 'footnotes',
default => null,
};
if (null !== $key && !isset($map[$key])) {
$map[$key] = (int) $col;
}
}
return $map;
}
/**
* Convertit une date "dd-mm-yyyy" en "yyyy-mm-dd". Null si format invalide
* ou date calendaire impossible.
*/
private static function parseDate(?string $raw): ?string
{
if (null === $raw || !preg_match('/^(\d{1,2})-(\d{1,2})-(\d{4})$/', $raw, $m)) {
return null;
}
$day = (int) $m[1];
$month = (int) $m[2];
$year = (int) $m[3];
if (!checkdate($month, $day, $year)) {
return null;
}
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
/**
* Eclate une cellule "Numero CAS" sur ';' en liste de chaines non vides.
*
* @return list<string>
*/
private static function splitCas(?string $raw): array
{
if (null === $raw) {
return [];
}
$parts = array_map('trim', explode(';', $raw));
return array_values(array_filter($parts, static fn (string $v): bool => '' !== $v));
}
/**
* Valeur d'une cellule par index : trim, null si absente/vide.
*
* @param array<int, mixed> $row
*/
private static function val(array $row, ?int $col): ?string
{
if (null === $col) {
return null;
}
$v = trim((string) ($row[$col] ?? ''));
return '' === $v ? null : $v;
}
/**
* Normalise un libelle d'en-tete : minuscules, sans accents ni apostrophes,
* espaces compresses (pour un matching robuste).
*/
private static function normalize(string $s): string
{
$s = str_replace(['', "'"], ' ', $s);
$s = (string) iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
$s = mb_strtolower($s);
return trim((string) preg_replace('/\s+/', ' ', $s));
}
}
@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Console;
use App\Module\Transport\Application\Idtf\IdtfSheetParser;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use PhpOffice\PhpSpreadsheet\IOFactory;
use RuntimeException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
use function array_slice;
use function count;
use function in_array;
use const JSON_THROW_ON_ERROR;
use const JSON_UNESCAPED_UNICODE;
/**
* ERP-149 : synchronise le referentiel des codes IDTF (regimes de nettoyage
* transport).
*
* Recupere l'export Excel depuis le generateur icrt-idtf.com (ou un fichier
* local), le parse et synchronise `idtf_product` de facon transactionnelle :
* upsert sur (schema, idtf_number), soft-delete des absents, journal dans
* `idtf_sync_log`. Idempotente (refresh complet).
*/
#[AsCommand(
name: 'app:idtf:sync',
description: 'Synchronise le referentiel des codes IDTF depuis l\'export Excel icrt-idtf.com (upsert + soft-delete + journal).',
)]
final class SyncIdtfCommand extends Command
{
private const string GENERATOR_URL = 'https://www.icrt-idtf.com/fr/excel-generator/';
/**
* Champs a cocher explicitement : `fields[]=all` ne deplie PAS les colonnes
* cote serveur (6 colonnes seulement). Cette liste donne l'export complet
* (11 colonnes). Cf. ERP-149 § 1.
*
* @var list<string>
*/
private const array EXPORT_FIELDS = [
'product_number_idtf',
'product_name',
'minimum_cleaning_regime',
'important_requirements',
'date_mandatory',
'related_products',
'formula',
'product_number_eural',
'product_number_cas',
'footnotes',
];
public function __construct(
private readonly Connection $connection,
private readonly HttpClientInterface $httpClient,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('schema', null, InputOption::VALUE_REQUIRED, "Module IDTF : 'road' (routier) ou 'water' (fluvial).", 'road')
->addOption('file', null, InputOption::VALUE_REQUIRED, "Chemin d'un .xlsx local (court-circuite le telechargement, utile pour tests/rejeu).")
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Analyse sans ecriture en base.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$schema = (string) $input->getOption('schema');
$dryRun = (bool) $input->getOption('dry-run');
$file = $input->getOption('file');
if (!in_array($schema, ['road', 'water'], true)) {
$io->error("--schema doit valoir 'road' ou 'water'.");
return Command::INVALID;
}
// 1. Recuperation du binaire xlsx (local ou via POST).
try {
$xlsx = null !== $file ? $this->readLocal((string) $file) : $this->downloadExport($schema);
} catch (Throwable $e) {
$io->error('Telechargement/lecture impossible : '.$e->getMessage());
return Command::FAILURE;
}
// 2. Parsing (xlsx -> matrice -> lignes normalisees).
try {
$parsed = IdtfSheetParser::parse($this->toMatrix($xlsx));
} catch (Throwable $e) {
$io->error('Parsing impossible : '.$e->getMessage());
return Command::FAILURE;
}
$rows = $parsed['rows'];
$exportDate = $parsed['exportDate'] ?? new DateTimeImmutable()->format('Y-m-d');
$io->section(sprintf('IDTF %s — export du %s', mb_strtoupper($schema), $exportDate));
$io->writeln(sprintf('%d lignes exploitables lues.', count($rows)));
if ($dryRun) {
$this->renderPreview($io, $rows);
$io->note(sprintf('Dry-run : aucune ecriture. (%d lignes au total)', count($rows)));
return Command::SUCCESS;
}
// 3. Sync transactionnelle : upsert -> soft-delete -> journal.
$run = new DateTimeImmutable()->format('Y-m-d H:i:s.u');
$this->connection->beginTransaction();
try {
$upserted = $this->upsertAll($schema, $exportDate, $rows, $run);
$deactivated = $this->deactivateMissing($schema, $run);
$this->log($schema, $exportDate, count($rows), $upserted, $deactivated);
$this->connection->commit();
} catch (Throwable $e) {
$this->connection->rollBack();
$io->error('Sync annulee (rollback) : '.$e->getMessage());
return Command::FAILURE;
}
$io->success(sprintf('%d upsert, %d desactive(s).', $upserted, $deactivated));
return Command::SUCCESS;
}
/**
* Rejoue le POST du generateur pour recuperer le binaire xlsx complet.
* Le formulaire poste sur lui-meme ; pas besoin de GET/cookies prealables.
*/
private function downloadExport(string $schema): string
{
// Corps construit a la main : http-client encoderait fields[] en
// indices numerotes, on veut bien des "fields[]=..." repetes.
$pairs = [
'schema='.$schema,
'type%5B%5D='.$schema,
'roadRegime%5B%5D=all',
'waterRegime%5B%5D=all',
'groups%5B%5D=all',
'products%5B%5D=all',
];
foreach (self::EXPORT_FIELDS as $field) {
$pairs[] = 'fields%5B%5D='.$field;
}
$pairs[] = 'generateExcel=';
$response = $this->httpClient->request('POST', self::GENERATOR_URL, [
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
'body' => implode('&', $pairs),
'timeout' => 90,
]);
$content = $response->getContent();
$contentType = $response->getHeaders(false)['content-type'][0] ?? '';
// Garde-fou : un HTML signifie un POST rejete (filtres/payload).
if (!str_contains($contentType, 'spreadsheet') && !str_starts_with($content, "PK\x03\x04")) {
throw new RuntimeException(sprintf('Reponse non-xlsx (content-type: %s). Verifie le payload.', $contentType));
}
return $content;
}
private function readLocal(string $path): string
{
$raw = @file_get_contents($path);
if (false === $raw) {
throw new RuntimeException(sprintf('Fichier illisible : %s', $path));
}
return $raw;
}
/**
* Charge le binaire xlsx via PhpSpreadsheet et retourne la feuille active
* sous forme de matrice 0-indexee (lignes/colonnes).
*
* @return array<int, array<int, mixed>>
*/
private function toMatrix(string $xlsx): array
{
$tmp = tempnam(sys_get_temp_dir(), 'idtf_').'.xlsx';
file_put_contents($tmp, $xlsx);
try {
// toArray(null, true, true, false) : colonnes 0-indexees.
return IOFactory::load($tmp)->getActiveSheet()->toArray(null, true, true, false);
} finally {
@unlink($tmp);
}
}
/**
* Upsert de toutes les lignes (cle naturelle = schema + idtf_number).
*
* @param list<array<string, mixed>> $rows
*/
private function upsertAll(string $schema, string $exportDate, array $rows, string $run): int
{
$sql = <<<'SQL'
INSERT INTO idtf_product
(idtf_number, schema, product_group, name, cleaning_regime, important_requirements,
mandatory_date, related_products, formula, eural_code, cas_numbers, footnotes,
source_export_date, is_active, last_synced_at)
VALUES
(:idtf, :schema, :grp, :name, :regime, :req, :mdate, :related, :formula, :eural,
CAST(:cas AS JSONB), :foot, :export, TRUE, :run)
ON CONFLICT (schema, idtf_number) DO UPDATE SET
product_group = EXCLUDED.product_group,
name = EXCLUDED.name,
cleaning_regime = EXCLUDED.cleaning_regime,
important_requirements = EXCLUDED.important_requirements,
mandatory_date = EXCLUDED.mandatory_date,
related_products = EXCLUDED.related_products,
formula = EXCLUDED.formula,
eural_code = EXCLUDED.eural_code,
cas_numbers = EXCLUDED.cas_numbers,
footnotes = EXCLUDED.footnotes,
source_export_date = EXCLUDED.source_export_date,
is_active = TRUE,
last_synced_at = EXCLUDED.last_synced_at
SQL;
$count = 0;
foreach ($rows as $r) {
$this->connection->executeStatement($sql, [
'idtf' => $r['idtf_number'],
'schema' => $schema,
'grp' => $r['product_group'],
'name' => $r['name'],
'regime' => $r['cleaning_regime'],
'req' => $r['important_requirements'],
'mdate' => $r['mandatory_date'],
'related' => $r['related_products'],
'formula' => $r['formula'],
'eural' => $r['eural_code'],
'cas' => json_encode($r['cas_numbers'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'foot' => $r['footnotes'],
'export' => $exportDate,
'run' => $run,
]);
++$count;
}
return $count;
}
/**
* Soft-delete : toute ligne du schema active non revue par ce run passe a
* is_active=false.
*/
private function deactivateMissing(string $schema, string $run): int
{
return (int) $this->connection->executeStatement(
'UPDATE idtf_product SET is_active = FALSE WHERE schema = :schema AND is_active = TRUE AND last_synced_at < :run',
['schema' => $schema, 'run' => $run],
);
}
private function log(string $schema, string $exportDate, int $total, int $upserted, int $deactivated): void
{
$this->connection->executeStatement(
<<<'SQL'
INSERT INTO idtf_sync_log (schema, export_date, rows_total, rows_upserted, rows_deactivated)
VALUES (:schema, :export, :total, :upserted, :deactivated)
SQL,
[
'schema' => $schema,
'export' => $exportDate,
'total' => $total,
'upserted' => $upserted,
'deactivated' => $deactivated,
],
);
}
/**
* @param list<array<string, mixed>> $rows
*/
private function renderPreview(SymfonyStyle $io, array $rows): void
{
$io->table(
['IDTF', 'Nom', 'Regime', 'CAS'],
array_map(static fn (array $r): array => [
(string) $r['idtf_number'],
mb_strimwidth((string) $r['name'], 0, 50, '…'),
(string) $r['cleaning_regime'],
implode(', ', $r['cas_numbers']),
], array_slice($rows, 0, 15)),
);
}
}
@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Post;
use App\Shared\Infrastructure\ApiPlatform\State\UploadedDocumentProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Reference technique d'un fichier televerse (infra generique Shared, ERP-154).
*
* Entite IMMUABLE : un document n'est jamais modifie apres creation (pas d'onglet
* edition cote front). Elle ne porte donc QUE `created_at` / `created_by` pas
* la paire `updated_*` et n'implemente volontairement pas Timestampable /
* Blamable (qui imposeraient les 4 colonnes). `created_at` est rempli par le
* FileUploader via l'horloge injectee ; `created_by` est positionne par le
* processor depuis l'utilisateur authentifie (null hors HTTP).
*
* Pas de `#[Auditable]` : c'est un enregistrement d'infrastructure (et non un
* agregat metier edite), sa tracabilite est portee par created_at / created_by.
*
* Operations API :
* - Post (/uploaded_documents, multipart) : `deserialize: false` le binaire
* n'est pas deserialise dans l'entite, le UploadedDocumentProcessor lit le
* fichier de la requete, delegue au FileUploader (validation MIME server-side,
* bornage taille, checksum, ecriture disque) puis persiste. MIME hors
* whitelist -> 422.
* - Get (/uploaded_documents/{id}) : necessaire pour qu'API Platform genere
* l'IRI renvoyee par le Post. Protege par IS_AUTHENTICATED_FULLY uniquement
* (pas de RBAC ni de cloisonnement tenant ici) : cette ressource est une
* infra GENERIQUE qui ne porte aucune notion de proprietaire metier. Le
* cloisonnement d'acces (qui peut voir quel document) est volontairement
* delegue au module CONSOMMATEUR (ex: la Decharge M4), qui exposera le
* document via sa propre ressource cloisonnee plutot que via cet endpoint
* technique. Ne renvoie que des metadonnees (jamais le binaire).
*
* Pas de GetCollection exposee (non requise) la regle de pagination ne
* s'applique donc pas ici.
*/
#[ORM\Entity]
#[ORM\Table(name: 'uploaded_document')]
#[ApiResource(
operations: [
new Get(
security: "is_granted('IS_AUTHENTICATED_FULLY')",
),
new Post(
// Entree multipart : le binaire arrive en multipart/form-data sous
// le champ « file ». Sans cet inputFormats, API Platform rejette la
// requete en 415.
inputFormats: ['multipart' => ['multipart/form-data']],
// Le fichier n'est pas deserialisable dans l'entite : le processor
// lit le binaire de la requete. La validation est portee par le
// FileUploader (MIME server-side, taille), pas par les contraintes.
deserialize: false,
validate: false,
security: "is_granted('IS_AUTHENTICATED_FULLY')",
processor: UploadedDocumentProcessor::class,
),
],
normalizationContext: ['groups' => ['uploaded_document:read']],
)]
class UploadedDocument
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['uploaded_document:read'])]
private ?int $id = null;
#[ORM\Column(name: 'original_filename', length: 255)]
#[Groups(['uploaded_document:read'])]
private string $originalFilename;
#[ORM\Column(name: 'stored_path', length: 512)]
#[Groups(['uploaded_document:read'])]
private string $storedPath;
#[ORM\Column(name: 'mime_type', length: 100)]
#[Groups(['uploaded_document:read'])]
private string $mimeType;
#[ORM\Column(name: 'size_bytes', type: 'integer')]
#[Groups(['uploaded_document:read'])]
private int $sizeBytes;
#[ORM\Column(name: 'checksum', length: 64)]
#[Groups(['uploaded_document:read'])]
private string $checksum;
#[ORM\Column(name: 'created_at', type: 'datetime_immutable')]
#[Groups(['uploaded_document:read'])]
private DateTimeImmutable $createdAt;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?UserInterface $createdBy = null;
public function __construct(
string $originalFilename,
string $storedPath,
string $mimeType,
int $sizeBytes,
string $checksum,
DateTimeImmutable $createdAt,
) {
$this->originalFilename = $originalFilename;
$this->storedPath = $storedPath;
$this->mimeType = $mimeType;
$this->sizeBytes = $sizeBytes;
$this->checksum = $checksum;
$this->createdAt = $createdAt;
}
public function getId(): ?int
{
return $this->id;
}
public function getOriginalFilename(): string
{
return $this->originalFilename;
}
public function getStoredPath(): string
{
return $this->storedPath;
}
public function getMimeType(): string
{
return $this->mimeType;
}
public function getSizeBytes(): int
{
return $this->sizeBytes;
}
public function getChecksum(): string
{
return $this->checksum;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getCreatedBy(): ?UserInterface
{
return $this->createdBy;
}
public function setCreatedBy(?UserInterface $user): void
{
$this->createdBy = $user;
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Exception;
/**
* Levee quand le fichier televerse depasse la taille maximale autorisee
* (FileUploader::MAX_SIZE_BYTES). Traduite en HTTP 422 par le processor.
*/
final class FileTooLargeException extends FileUploadException
{
public function __construct(int $size, int $maxSize)
{
parent::__construct(sprintf(
'Le fichier (%d octets) dépasse la taille maximale autorisée (%d octets).',
$size,
$maxSize,
));
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Exception;
use RuntimeException;
/**
* Exception de base des erreurs de televersement (FileUploader).
*
* Decouplee de HTTP : le service Shared\Infrastructure\Upload\FileUploader leve
* une de ces exceptions metier, et c'est la couche API (UploadedDocumentProcessor)
* qui la traduit en reponse HTTP 422.
*/
class FileUploadException extends RuntimeException {}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Exception;
/**
* Levee quand le type MIME detecte server-side n'appartient pas a la whitelist
* du FileUploader (PDF + images). Traduite en HTTP 422 par le processor.
*/
final class UnsupportedMimeTypeException extends FileUploadException
{
/**
* @param list<string> $allowed Types MIME autorises
*/
public function __construct(string $mimeType, array $allowed)
{
parent::__construct(sprintf(
'Le type de fichier « %s » n\'est pas autorisé. Types acceptés : %s.',
$mimeType,
implode(', ', $allowed),
));
}
}
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Shared\Domain\Exception\FileUploadException;
use App\Shared\Infrastructure\Upload\FileUploader;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Security\Core\User\UserInterface;
use Throwable;
/**
* Processor d'ecriture de l'upload generique (POST /api/uploaded_documents).
*
* L'operation Post est en `deserialize: false` : le binaire n'est pas mappe sur
* l'entite. Ce processor lit le fichier multipart de la requete (champ « file »),
* delegue au FileUploader (validation MIME server-side, bornage taille, checksum,
* ecriture disque), positionne l'auteur (created_by) puis persiste via le
* processor Doctrine standard. Le retour est l'entite, qu'API Platform serialise
* en JSON-LD (avec son @id / IRI).
*
* Mapping des erreurs :
* - fichier absent -> 422 ;
* - MIME hors whitelist / fichier trop volumineux (FileUploadException) -> 422.
*
* Si la persistance Doctrine echoue APRES l'ecriture disque, le fichier physique
* deja deplace est supprime (compensation) pour ne pas laisser de binaire orphelin.
*
* @implements ProcessorInterface<mixed, mixed>
*/
final class UploadedDocumentProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly FileUploader $fileUploader,
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
$request = $this->requestStack->getCurrentRequest();
$file = $request?->files->get('file');
if (!$file instanceof UploadedFile) {
throw new UnprocessableEntityHttpException('Aucun fichier fourni (champ « file » attendu).');
}
try {
$document = $this->fileUploader->upload($file);
} catch (FileUploadException $e) {
// MIME hors whitelist ou fichier trop volumineux -> 422 avec le
// message metier explicite porte par l'exception.
throw new UnprocessableEntityHttpException($e->getMessage(), $e);
}
$user = $this->security->getUser();
if ($user instanceof UserInterface) {
$document->setCreatedBy($user);
}
try {
return $this->persistProcessor->process($document, $operation, $uriVariables, $context);
} catch (Throwable $e) {
// La persistance a echoue APRES l'ecriture disque (erreur DB, FK...) :
// on supprime le fichier orphelin pour ne pas le laisser sans ligne
// uploaded_document correspondante, puis on relaie l'erreur.
$this->fileUploader->remove($document);
throw $e;
}
}
}
@@ -36,6 +36,18 @@ final class ColumnCommentsCatalog
public static function comments(): array public static function comments(): array
{ {
return [ return [
'uploaded_document' => [
'_table' => 'Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.',
'id' => 'Identifiant interne auto-incremente.',
'original_filename' => 'Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.',
'stored_path' => 'Chemin relatif du fichier sous var/uploads (ex: 2026/06/<hash>.pdf) — nom genere aleatoirement, jamais le nom client.',
'mime_type' => 'Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).',
'size_bytes' => 'Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.',
'checksum' => 'Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).',
'created_at' => 'Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).',
'created_by' => 'ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.',
],
'audit_log' => [ 'audit_log' => [
'_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.", '_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.",
'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).", 'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).",
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Upload;
use App\Shared\Domain\Entity\UploadedDocument;
use App\Shared\Domain\Exception\FileTooLargeException;
use App\Shared\Domain\Exception\FileUploadException;
use App\Shared\Domain\Exception\UnsupportedMimeTypeException;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* Service generique de televersement de fichiers (infra Shared, ERP-154).
*
* Responsabilites :
* - Validation du type MIME SERVER-SIDE via `getMimeType()` (detection finfo
* sur le contenu reel) JAMAIS `getClientMimeType()`, spoofable par le
* client (regle backend.md « Upload de fichiers »).
* - Whitelist MIME explicite (PDF + images courantes).
* - Bornage de la taille (MAX_SIZE_BYTES).
* - Calcul du checksum sha256 (controle d integrite) AVANT le deplacement.
* - Ecriture disque sous `var/uploads/{yyyy}/{mm}/` avec un nom genere
* aleatoirement (jamais le nom client, qui reste une simple metadonnee).
*
* Le service est volontairement decouple de HTTP au-dela du type UploadedFile :
* il leve des exceptions metier (FileUploadException), traduites en 422 par le
* UploadedDocumentProcessor.
*/
final class FileUploader
{
/**
* Types MIME autorises (detectes server-side) : PDF + images courantes.
*
* @var list<string>
*/
public const ALLOWED_MIME_TYPES = [
'application/pdf',
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
];
/**
* Taille maximale autorisee : 10 Mo.
*/
public const MAX_SIZE_BYTES = 10 * 1024 * 1024;
public function __construct(
// Racine de stockage des fichiers televerses (hors web root, sous var/).
#[Autowire('%kernel.project_dir%/var/uploads')]
private readonly string $uploadBaseDir,
private readonly ClockInterface $clock,
) {}
/**
* Valide, calcule l empreinte, deplace le fichier sur disque et retourne
* un UploadedDocument NON persiste (le caller le persiste).
*
* @throws UnsupportedMimeTypeException si le MIME server-side est hors whitelist
* @throws FileTooLargeException si le fichier depasse MAX_SIZE_BYTES
*/
public function upload(UploadedFile $file): UploadedDocument
{
// Detection MIME server-side (finfo sur le contenu) — jamais le MIME
// declare par le client.
$mimeType = $file->getMimeType() ?? 'application/octet-stream';
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
throw new UnsupportedMimeTypeException($mimeType, self::ALLOWED_MIME_TYPES);
}
// getSize() peut renvoyer false si le fichier est illisible.
$size = $file->getSize();
if (false === $size || $size > self::MAX_SIZE_BYTES) {
throw new FileTooLargeException(false === $size ? 0 : $size, self::MAX_SIZE_BYTES);
}
// Checksum AVANT le move : le chemin du fichier change apres deplacement.
// hash_file renvoie false si le fichier temporaire est illisible (I/O) :
// on echoue proprement plutot que de propager un TypeError opaque au
// constructeur (parametre $checksum type string).
$checksum = hash_file('sha256', $file->getPathname());
if (false === $checksum) {
throw new FileUploadException('Impossible de lire le fichier televerse pour en calculer l\'empreinte.');
}
$now = $this->clock->now();
$relativeDir = $now->format('Y').'/'.$now->format('m');
$targetDir = $this->uploadBaseDir.'/'.$relativeDir;
// Nom de stockage genere aleatoirement : evite les collisions et toute
// injection via le nom client. Extension deduite du MIME.
$extension = $file->guessExtension() ?: 'bin';
$storedName = bin2hex(random_bytes(16)).'.'.$extension;
// Le nom d origine est conserve uniquement comme metadonnee d affichage,
// borne a la longueur de colonne (255).
$originalFilename = mb_substr($file->getClientOriginalName(), 0, 255);
$file->move($targetDir, $storedName);
return new UploadedDocument(
originalFilename: $originalFilename,
storedPath: $relativeDir.'/'.$storedName,
mimeType: $mimeType,
sizeBytes: $size,
checksum: $checksum,
createdAt: $now,
);
}
/**
* Supprime le fichier physique d'un document (compensation lorsque la
* persistance echoue APRES l'ecriture disque). Best-effort : silencieux si
* le fichier a deja disparu. Evite d'accumuler des binaires orphelins non
* references en base.
*/
public function remove(UploadedDocument $document): void
{
$path = $this->uploadBaseDir.'/'.$document->getStoredPath();
if (is_file($path)) {
@unlink($path);
}
}
}
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Application\Idtf;
use App\Module\Transport\Application\Idtf\IdtfSheetParser;
use PHPUnit\Framework\TestCase;
use RuntimeException;
/**
* @internal
*/
final class IdtfSheetParserTest extends TestCase
{
public function testExtractsExportDate(): void
{
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
self::assertSame('2026-06-12', $parsed['exportDate']);
}
public function testParsesAndNormalizesFirstRow(): void
{
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
$row = $parsed['rows'][0];
self::assertSame(30748, $row['idtf_number']);
self::assertSame('Argiles avec régime de nettoyage C', $row['name']);
self::assertSame('C', $row['cleaning_regime']);
self::assertSame('2026-04-02', $row['mandatory_date']);
self::assertSame('Al2O3', $row['formula']);
self::assertSame('01 01 01', $row['eural_code']);
self::assertSame(['7631-86-9', '1344-28-1'], $row['cas_numbers']);
self::assertSame('Note 1', $row['footnotes']);
}
public function testSkipsEmptyAndNonNumericRows(): void
{
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
// 2 lignes exploitables (30748 et 30744) ; vide + "abc" ignorees.
self::assertCount(2, $parsed['rows']);
self::assertSame(30744, $parsed['rows'][1]['idtf_number']);
}
public function testEmptyOptionalCellsBecomeNullAndCasEmpty(): void
{
$parsed = IdtfSheetParser::parse($this->sampleMatrix());
$row = $parsed['rows'][1]; // 30744
self::assertNull($row['mandatory_date']);
self::assertNull($row['formula']);
self::assertNull($row['product_group']);
self::assertSame([], $row['cas_numbers']);
}
public function testColumnOrderIsResolvedByLabel(): void
{
// En-tete dans un ordre different : le mapping doit suivre les libelles.
$matrix = [
['Export date: 1-1-2026'],
['Numéro CAS', 'Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'],
['7440-44-0', '99', 'Carbone', 'B'],
];
$parsed = IdtfSheetParser::parse($matrix);
$row = $parsed['rows'][0];
self::assertSame(99, $row['idtf_number']);
self::assertSame('Carbone', $row['name']);
self::assertSame('B', $row['cleaning_regime']);
self::assertSame(['7440-44-0'], $row['cas_numbers']);
}
public function testThrowsWhenHeaderMissing(): void
{
$this->expectException(RuntimeException::class);
IdtfSheetParser::parse([['foo', 'bar'], ['1', '2']]);
}
public function testExportDateNullWhenAbsent(): void
{
$matrix = [
['Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'],
['1', 'X', 'A'],
];
self::assertNull(IdtfSheetParser::parse($matrix)['exportDate']);
}
/**
* Matrice representative de l'export reel : preambule (lignes 0-1), ligne
* vide (2), en-tete (3) puis donnees.
*
* @return array<int, array<int, mixed>>
*/
private function sampleMatrix(): array
{
return [
['Export date: 12-6-2026'],
['Changes in the database after this date...'],
[],
['Numéro IDTF', 'Product Group', 'Nom de la marchandise', 'Régime de nettoyage', 'Exigences importantes', 'Date dapplication obligatoire', 'Produits apparentés', 'Formule', 'Code EURAL', 'Numéro CAS', 'Annotations'],
['30748', 'Substances inorganiques', 'Argiles avec régime de nettoyage C', 'C', 'Exigence X', '02-04-2026', 'Poudre argile', 'Al2O3', '01 01 01', '7631-86-9 ; 1344-28-1', 'Note 1'],
['', '', '', '', '', '', '', '', '', '', ''],
['abc', 'ligne non numerique a ignorer', '', '', '', '', '', '', '', '', ''],
['30744', '', 'Additifs alimentaires', 'A', '', '', '', '', '', '', ''],
];
}
}
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Infrastructure\Console;
use Doctrine\DBAL\Connection;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
/**
* Test fonctionnel de `app:idtf:sync` via --file : genere un vrai .xlsx, le
* passe a la commande et verifie le parsing, l'upsert, le journal et le
* soft-delete (chemin complet IOFactory -> parser -> DBAL).
*
* @internal
*/
final class SyncIdtfCommandTest extends KernelTestCase
{
private Connection $connection;
protected function setUp(): void
{
self::bootKernel();
/** @var Connection $connection */
$connection = self::getContainer()->get('doctrine.dbal.default_connection');
$this->connection = $connection;
$this->purge();
}
protected function tearDown(): void
{
$this->purge();
parent::tearDown();
}
public function testSyncParsesXlsxUpsertsAndLogs(): void
{
$path = $this->makeXlsx([
['Export date: 12-6-2026'],
['Avertissement preambule'],
[],
['Numéro IDTF', 'Product Group', 'Nom de la marchandise', 'Régime de nettoyage', 'Exigences importantes', 'Date dapplication obligatoire', 'Produits apparentés', 'Formule', 'Code EURAL', 'Numéro CAS', 'Annotations'],
['30748', 'Inorganiques', 'Argiles régime C', 'C', 'Exig X', '02-04-2026', 'Poudre', 'Al2O3', '01 01 01', '7631-86-9 ; 1344-28-1', 'Note'],
['', '', '', '', '', '', '', '', '', '', ''],
['30744', '', 'Additifs', 'A', '', '', '', '', '', '', ''],
]);
$tester = $this->runSync($path);
$tester->assertCommandIsSuccessful();
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM idtf_product'));
$row = $this->connection->fetchAssociative("SELECT * FROM idtf_product WHERE idtf_number = 30748 AND schema = 'road'");
self::assertNotFalse($row);
self::assertSame('Argiles régime C', $row['name']);
self::assertSame('C', $row['cleaning_regime']);
self::assertSame('2026-04-02', $row['mandatory_date']);
self::assertSame('2026-06-12', $row['source_export_date']);
self::assertSame(['7631-86-9', '1344-28-1'], json_decode((string) $row['cas_numbers'], true));
$log = $this->connection->fetchAssociative('SELECT * FROM idtf_sync_log ORDER BY id DESC LIMIT 1');
self::assertNotFalse($log);
self::assertSame('road', $log['schema']);
self::assertSame('2026-06-12', $log['export_date']);
self::assertSame(2, (int) $log['rows_total']);
self::assertSame(2, (int) $log['rows_upserted']);
self::assertSame(0, (int) $log['rows_deactivated']);
}
public function testSecondSyncSoftDeletesMissing(): void
{
$header = ['Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'];
$this->runSync($this->makeXlsx([
['Export date: 1-6-2026'],
$header,
['100', 'Produit 100', 'A'],
['200', 'Produit 200', 'B'],
]))->assertCommandIsSuccessful();
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM idtf_product WHERE is_active = TRUE'));
// 2e export sans 200 -> soft-delete de 200, mise a jour de 100.
$tester = $this->runSync($this->makeXlsx([
['Export date: 2-6-2026'],
$header,
['100', 'Produit 100 maj', 'C'],
]));
$tester->assertCommandIsSuccessful();
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM idtf_product'));
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM idtf_product WHERE is_active = TRUE'));
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM idtf_product WHERE idtf_number = 200 AND is_active = FALSE'));
$row100 = $this->connection->fetchAssociative('SELECT * FROM idtf_product WHERE idtf_number = 100');
self::assertNotFalse($row100);
self::assertSame('Produit 100 maj', $row100['name']);
self::assertSame('C', $row100['cleaning_regime']);
$log = $this->connection->fetchAssociative('SELECT * FROM idtf_sync_log ORDER BY id DESC LIMIT 1');
self::assertNotFalse($log);
self::assertSame(1, (int) $log['rows_upserted']);
self::assertSame(1, (int) $log['rows_deactivated']);
}
public function testInvalidSchemaIsRejected(): void
{
$path = $this->makeXlsx([
['Numéro IDTF', 'Nom de la marchandise', 'Régime de nettoyage'],
['1', 'X', 'A'],
]);
$application = new Application(self::$kernel);
$tester = new CommandTester($application->find('app:idtf:sync'));
$exitCode = $tester->execute(['--file' => $path, '--schema' => 'air']);
@unlink($path);
self::assertSame(2, $exitCode); // Command::INVALID
self::assertSame(0, $this->countRows('SELECT COUNT(*) FROM idtf_product'));
}
/**
* @param array<int, array<int, mixed>> $matrix
*/
private function makeXlsx(array $matrix): string
{
$spreadsheet = new Spreadsheet();
$spreadsheet->getActiveSheet()->fromArray($matrix, null, 'A1', true);
$path = tempnam(sys_get_temp_dir(), 'idtf_').'.xlsx';
new Xlsx($spreadsheet)->save($path);
$spreadsheet->disconnectWorksheets();
return $path;
}
private function runSync(string $path): CommandTester
{
$application = new Application(self::$kernel);
$tester = new CommandTester($application->find('app:idtf:sync'));
$tester->execute(['--file' => $path, '--schema' => 'road']);
@unlink($path);
return $tester;
}
private function countRows(string $sql): int
{
return (int) $this->connection->fetchOne($sql);
}
private function purge(): void
{
$this->connection->executeStatement('DELETE FROM idtf_product');
$this->connection->executeStatement('DELETE FROM idtf_sync_log');
}
}
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Tests\Shared\Api;
use App\Shared\Domain\Entity\UploadedDocument;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* Tests fonctionnels de l'endpoint d'upload generique (ERP-154).
*
* Couvre :
* - POST multipart d'un PDF valide -> 201, IRI renvoyee, ligne persistee,
* checksum sha256 calcule cote serveur ;
* - POST d'un MIME hors whitelist (text/plain) -> 422 ;
* - POST sans fichier -> 422 ;
* - POST anonyme -> 401 (acces /api protege globalement).
*
* @internal
*/
final class UploadedDocumentApiTest extends AbstractApiTestCase
{
private const string ENDPOINT = '/api/uploaded_documents';
/** @var list<string> */
private array $tempFiles = [];
protected function tearDown(): void
{
foreach ($this->tempFiles as $path) {
if (is_file($path)) {
@unlink($path);
}
}
parent::tearDown();
}
public function testUploadValidPdfReturnsIriAndPersistsRowWithChecksum(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$content = $this->minimalPdf();
$file = $this->makeUploadedFile($content, 'facture.pdf');
$response = $client->request('POST', self::ENDPOINT, [
'headers' => ['Accept' => 'application/ld+json'],
'extra' => ['files' => ['file' => $file]],
]);
self::assertResponseStatusCodeSame(201);
$data = $response->toArray();
self::assertArrayHasKey('@id', $data);
self::assertStringStartsWith(self::ENDPOINT.'/', $data['@id']);
self::assertSame('facture.pdf', $data['originalFilename']);
self::assertSame('application/pdf', $data['mimeType']);
self::assertSame(\strlen($content), $data['sizeBytes']);
self::assertSame(hash('sha256', $content), $data['checksum']);
self::assertSame(64, \strlen($data['checksum']));
// La ligne est bien persistee et relisible via le repository.
$id = $data['id'];
$document = $this->getEm()->getRepository(UploadedDocument::class)->find($id);
self::assertInstanceOf(UploadedDocument::class, $document);
self::assertSame(hash('sha256', $content), $document->getChecksum());
}
public function testUploadDisallowedMimeTypeReturns422(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$file = $this->makeUploadedFile('just some plain text content', 'note.txt');
$client->request('POST', self::ENDPOINT, [
'headers' => ['Accept' => 'application/ld+json'],
'extra' => ['files' => ['file' => $file]],
]);
self::assertResponseStatusCodeSame(422);
}
public function testUploadWithoutFileReturns422(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$client->request('POST', self::ENDPOINT, [
'headers' => ['Accept' => 'application/ld+json'],
'extra' => ['files' => []],
]);
self::assertResponseStatusCodeSame(422);
}
public function testUploadAnonymousIsRejected(): void
{
$client = self::createClient();
$file = $this->makeUploadedFile($this->minimalPdf(), 'facture.pdf');
$client->request('POST', self::ENDPOINT, [
'headers' => ['Accept' => 'application/ld+json'],
'extra' => ['files' => ['file' => $file]],
]);
self::assertResponseStatusCodeSame(401);
}
/**
* Cree un UploadedFile en mode test (move() autorise hors contexte HTTP).
*/
private function makeUploadedFile(string $content, string $clientName): UploadedFile
{
$path = sys_get_temp_dir().'/erp154-api-'.bin2hex(random_bytes(4));
file_put_contents($path, $content);
$this->tempFiles[] = $path;
return new UploadedFile($path, $clientName, null, null, true);
}
/**
* Contenu PDF minimal valide (entete `%PDF-1.4` -> finfo `application/pdf`).
*/
private function minimalPdf(): string
{
return "%PDF-1.4\n"
."1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
."2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
."3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
."trailer<</Root 1 0 R/Size 4>>\n"
."%%EOF\n";
}
}
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Tests\Shared\Infrastructure\Upload;
use App\Shared\Domain\Exception\FileTooLargeException;
use App\Shared\Domain\Exception\UnsupportedMimeTypeException;
use App\Shared\Infrastructure\Upload\FileUploader;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* Tests unitaires du service generique de televersement (ERP-154).
*
* Couvre : rejet d'un MIME hors whitelist, rejet d'un fichier trop volumineux,
* et le chemin nominal (checksum sha256 calcule, taille/MIME captures, fichier
* ecrit sous var/uploads/{yyyy}/{mm}/ avec horodatage de l'horloge injectee).
*
* @internal
*/
final class FileUploaderTest extends TestCase
{
private string $uploadBaseDir;
/** @var list<string> */
private array $tempFiles = [];
protected function setUp(): void
{
$this->uploadBaseDir = sys_get_temp_dir().'/erp154-uploads-'.bin2hex(random_bytes(4));
}
protected function tearDown(): void
{
// Nettoyage des fichiers sources et de l'arborescence de destination.
foreach ($this->tempFiles as $path) {
if (is_file($path)) {
@unlink($path);
}
}
$this->removeDirectory($this->uploadBaseDir);
}
public function testRejectsMimeTypeOutsideWhitelist(): void
{
$uploader = $this->createUploader();
$file = $this->makeUploadedFile('hello world plain text', 'note.txt');
$this->expectException(UnsupportedMimeTypeException::class);
$uploader->upload($file);
}
public function testRejectsFileLargerThanMaxSize(): void
{
$uploader = $this->createUploader();
// Contenu PDF valide mais artificiellement gonfle au-dela de la borne.
$content = "%PDF-1.4\n".str_repeat('A', FileUploader::MAX_SIZE_BYTES + 1);
$file = $this->makeUploadedFile($content, 'huge.pdf');
$this->expectException(FileTooLargeException::class);
$uploader->upload($file);
}
public function testStoresPdfAndComputesSha256Checksum(): void
{
$content = $this->minimalPdf();
$clock = new MockClock(new DateTimeImmutable('2026-06-15 10:00:00'));
$uploader = $this->createUploader($clock);
$file = $this->makeUploadedFile($content, 'facture.pdf');
$document = $uploader->upload($file);
self::assertSame('facture.pdf', $document->getOriginalFilename());
self::assertSame('application/pdf', $document->getMimeType());
self::assertSame(\strlen($content), $document->getSizeBytes());
self::assertSame(hash('sha256', $content), $document->getChecksum());
self::assertSame(64, \strlen($document->getChecksum()));
// Chemin relatif date selon l'horloge injectee (2026/06).
self::assertStringStartsWith('2026/06/', $document->getStoredPath());
self::assertFileExists($this->uploadBaseDir.'/'.$document->getStoredPath());
// Le fichier ecrit a bien le contenu d'origine (checksum coherent).
self::assertSame(
$document->getChecksum(),
hash_file('sha256', $this->uploadBaseDir.'/'.$document->getStoredPath()),
);
}
public function testRemoveDeletesStoredFile(): void
{
$uploader = $this->createUploader();
$file = $this->makeUploadedFile($this->minimalPdf(), 'facture.pdf');
$document = $uploader->upload($file);
$storedPath = $this->uploadBaseDir.'/'.$document->getStoredPath();
self::assertFileExists($storedPath);
// Compensation : remove() efface le fichier physique...
$uploader->remove($document);
self::assertFileDoesNotExist($storedPath);
// ...et reste silencieux si on le rappelle alors que le fichier a disparu.
$uploader->remove($document);
self::assertFileDoesNotExist($storedPath);
}
private function createUploader(?MockClock $clock = null): FileUploader
{
return new FileUploader($this->uploadBaseDir, $clock ?? new MockClock());
}
/**
* Cree un UploadedFile en mode test (move() autorise hors contexte HTTP).
*/
private function makeUploadedFile(string $content, string $clientName): UploadedFile
{
$path = sys_get_temp_dir().'/erp154-src-'.bin2hex(random_bytes(4));
file_put_contents($path, $content);
$this->tempFiles[] = $path;
// Le 5e argument `test: true` court-circuite move_uploaded_file().
return new UploadedFile($path, $clientName, null, null, true);
}
/**
* Contenu PDF minimal valide l'entete `%PDF-1.4` suffit a faire detecter
* `application/pdf` par finfo (getMimeType server-side).
*/
private function minimalPdf(): string
{
return "%PDF-1.4\n"
."1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
."2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
."3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\n"
."trailer<</Root 1 0 R/Size 4>>\n"
."%%EOF\n";
}
private function removeDirectory(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = scandir($dir) ?: [];
foreach ($items as $item) {
if ('.' === $item || '..' === $item) {
continue;
}
$path = $dir.'/'.$item;
is_dir($path) ? $this->removeDirectory($path) : @unlink($path);
}
@rmdir($dir);
}
}