Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1b18cfbbe | |||
| 5734aaef54 | |||
| 597c63bb2e | |||
| 8046de76c6 | |||
| 1ef4215ebf | |||
| 3b474f83f5 | |||
| c60daebf3e | |||
| 6dab7cfd17 | |||
| c1fcd9a7c8 | |||
| 18c88156e5 | |||
| c0fa00c9c5 | |||
| e688fe7e0b | |||
| 7d2812cea6 | |||
| daa8224b8b | |||
| 7012306a78 | |||
| 397fb22c62 | |||
| 13d4a08bc9 | |||
| aa23189fe1 | |||
| dc75945f3e | |||
| 2be9cd05d4 | |||
| f61e189441 | |||
| 9d9f9861b1 | |||
| 39071cbec0 | |||
| b82acdac01 | |||
| 8b8fb8c2aa | |||
| f9fec3e908 | |||
| 4f8ed075b6 | |||
| 1e783bd753 |
@@ -12,6 +12,9 @@ api_platform:
|
||||
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
|
||||
# en dehors de Domain/Entity : AuditLogResource, etc.
|
||||
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
|
||||
# Entites techniques partagees portant un #[ApiResource]
|
||||
# (UploadedDocument — infra upload generique ERP-154).
|
||||
- '%kernel.project_dir%/src/Shared/Domain/Entity'
|
||||
formats:
|
||||
jsonld: ['application/ld+json']
|
||||
json: ['application/json']
|
||||
|
||||
@@ -14,16 +14,23 @@ doctrine:
|
||||
# mappee :
|
||||
# - `audit_log` : append-only via DBAL brut (AuditLogWriter) pour
|
||||
# eviter la recursion du listener Doctrine.
|
||||
# - `qualimat_carrier` / `qualimat_sync_log` : referentiel
|
||||
# transporteurs synchronise en DBAL brut (upsert `ON CONFLICT`)
|
||||
# par `app:qualimat:sync`, hors ORM.
|
||||
# - `qualimat_sync_log` : journal de synchro transporteurs
|
||||
# QUALIMAT, ecrit en DBAL brut par `app:qualimat:sync`, hors ORM.
|
||||
# NB : `qualimat_carrier` n'est PLUS filtree depuis M4 (ERP-155) :
|
||||
# elle est desormais mappee en LECTURE SEULE par l'entite
|
||||
# App\Module\Transport\Domain\Entity\QualimatCarrier (cible de la
|
||||
# FK editable carrier.qualimat_carrier_id). Son mapping reproduit
|
||||
# a l'identique le DDL de la migration ERP-39 (unique siret, index
|
||||
# is_active, TIMESTAMP(6)) -> schema:update reste un no-op.
|
||||
# - `idtf_product` / `idtf_sync_log` : referentiel codes IDTF
|
||||
# synchronise en DBAL brut par `app:idtf:sync`, hors ORM.
|
||||
# Sans ce filtre, schema:update les considere comme "orphelines" et
|
||||
# genere un `DROP TABLE` qui casse la base de test apres chaque
|
||||
# `make test-db-setup` (la migration les a creees, schema:update les
|
||||
# supprime juste apres). Creation / suppression restent pilotees par
|
||||
# les migrations (audit_log : Version20260420202749 ; qualimat :
|
||||
# Version20260612150000).
|
||||
schema_filter: '~^(?!(?:audit_log|qualimat_carrier|qualimat_sync_log)$).+~'
|
||||
# Version20260612150000 ; idtf : Version20260612160000).
|
||||
schema_filter: '~^(?!(?:audit_log|qualimat_sync_log|idtf_product|idtf_sync_log)$).+~'
|
||||
audit:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
orm:
|
||||
@@ -47,7 +54,26 @@ doctrine:
|
||||
# Permet au module Commercial de referencer une Category via le contrat
|
||||
# Shared sans importer la classe concrete du module Catalog (regle n°1).
|
||||
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
|
||||
# Cibles des ManyToOne de CarrierPrice (M4 Transport, onglet Prix) :
|
||||
# permet au module Transport de referencer Client / Supplier et leurs
|
||||
# adresses (M1/M2 Commercial) via des contrats Shared sans importer les
|
||||
# classes concretes (regle n°1). L'embed JSON passe par les read-groups
|
||||
# des entites concretes (client:read / supplier:read / ...).
|
||||
App\Shared\Domain\Contract\ClientInterface: App\Module\Commercial\Domain\Entity\Client
|
||||
App\Shared\Domain\Contract\ClientAddressInterface: App\Module\Commercial\Domain\Entity\ClientAddress
|
||||
App\Shared\Domain\Contract\SupplierInterface: App\Module\Commercial\Domain\Entity\Supplier
|
||||
App\Shared\Domain\Contract\SupplierAddressInterface: App\Module\Commercial\Domain\Entity\SupplierAddress
|
||||
mappings:
|
||||
# Mapping des entites techniques partagees (src/Shared/Domain/Entity).
|
||||
# Premier occupant : UploadedDocument (infra upload generique ERP-154).
|
||||
# Necessaire car les entites Shared ne sont pas couvertes par
|
||||
# l'auto_mapping (qui ne cible que les bundles).
|
||||
Shared:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Shared/Domain/Entity'
|
||||
prefix: 'App\Shared\Domain\Entity'
|
||||
alias: Shared
|
||||
Core:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
@@ -96,6 +122,17 @@ doctrine:
|
||||
dir: '%kernel.project_dir%/src/Module/Technique/Domain/Entity'
|
||||
prefix: 'App\Module\Technique\Domain\Entity'
|
||||
alias: Technique
|
||||
# Mapping inconditionnel du module Transport (meme logique que Technique) :
|
||||
# les tables transporteurs (carrier + sous-collections) creees par la
|
||||
# migration M4 (Version20260615150000) et le mapping lecture-seule de
|
||||
# qualimat_carrier (referentiel ERP-39) doivent etre connus de l'ORM.
|
||||
# L'activation fonctionnelle passe par config/modules.php.
|
||||
Transport:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Transport/Domain/Entity'
|
||||
prefix: 'App\Module\Transport\Domain\Entity'
|
||||
alias: Transport
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
# Active le composant HTTP Client (symfony/http-client) et enregistre
|
||||
# l'autowiring de HttpClientInterface. Utilise par les commandes de
|
||||
# synchronisation de referentiels externes (QUALIMAT, IDTF...).
|
||||
#
|
||||
# User-Agent navigateur neutre : les sources (qualimat.org sous WordPress/WAF,
|
||||
# icrt-idtf.com) filtrent souvent les UA de bibliotheque/vides ; un UA de type
|
||||
# navigateur evite les blocages anti-bot sans reveler l'application.
|
||||
framework:
|
||||
http_client:
|
||||
default_options:
|
||||
timeout: 30
|
||||
headers:
|
||||
User-Agent: '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'
|
||||
|
||||
+13
-1
@@ -100,8 +100,20 @@ return [
|
||||
// individuelles"), ajouter : 'permission' => 'core.admin.access'.
|
||||
[
|
||||
'label' => 'sidebar.administration.section',
|
||||
'icon' => 'mdi:cog-outline',
|
||||
'icon' => 'mdi:file-settings-cog-outline',
|
||||
'items' => [
|
||||
// Transport — Repertoire transporteurs (M4, ERP-164). Rattache a
|
||||
// l'Administration (premier item) plutot qu'a une section dediee :
|
||||
// referentiel global de configuration applicative, sans cloisonnement
|
||||
// par site. Reste gate par sa propre permission `transport.carriers.view`
|
||||
// (Admin / Bureau / Commerciale) et son module owner `transport`.
|
||||
[
|
||||
'label' => 'sidebar.transport.carriers',
|
||||
'to' => '/carriers',
|
||||
'icon' => 'mdi:truck-outline',
|
||||
'module' => 'transport',
|
||||
'permission' => 'transport.carriers.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.core.roles',
|
||||
'to' => '/admin/roles',
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.124'
|
||||
app.version: '0.1.129'
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# M4 — Plan maître worktrees (back, Matthieu)
|
||||
|
||||
> **Rôle de ce fichier** : vue d'ensemble que la *conversation maître* tient à jour.
|
||||
> Chaque worktree = une conversation Claude isolée + une branche + une PR vers `develop`.
|
||||
> Les prompts à coller sont dans `WT*.md`.
|
||||
|
||||
## Principe
|
||||
|
||||
- 1 worktree = 1 branche partant de `origin/develop` (à jour des deps).
|
||||
- 1 ticket = 1 PR atomique vers **`develop`** (jamais `main`).
|
||||
- Commit autorisé sur la branche du worktree (ces prompts SONT la demande explicite) ;
|
||||
`git commit --no-verify` OK si `make test` est déjà vert (le hook relance toute la suite).
|
||||
- **Chaque worktree ouvre SA PR** vers `develop` en fin de tâche (cf. bloc PR ci-dessous).
|
||||
|
||||
## Bloc PR standard (repris dans chaque prompt)
|
||||
|
||||
```bash
|
||||
git push -u origin <branche>
|
||||
tea pr create --base develop --head <branche> \
|
||||
--title "<type>(<scope>) : <titre>" \
|
||||
--description "Résumé + lien ticket Lesstime ERP-XXX"
|
||||
```
|
||||
Puis **labelliser la PR via l'API Gitea** (tea ne pose pas les labels en CLI — `gitea.malio.fr`).
|
||||
Cible **`develop`**, jamais `main`. **Aucune mention de Claude/IA** dans titre ou description.
|
||||
|
||||
## Vagues & ordre de merge
|
||||
|
||||
```
|
||||
VAGUE 0 (en parallèle, dès maintenant)
|
||||
WT1 1.2 upload Shared base: origin/develop ──┐
|
||||
WT2 1.1 RBAC + sidebar base: origin/develop (≥ERP-150) ──┤ indépendants
|
||||
│
|
||||
VAGUE 1 (critique, séquentiel) │
|
||||
WT3 1.3 migration + 1.5 entités/resource/provider + i18n audit
|
||||
base: origin/develop APRÈS merge WT1 (FK uploaded_document)
|
||||
⭐ livre le CONTRAT JSON liste+détail → débloque le front (Tristan)
|
||||
|
||||
VAGUE 2 (fan-out, tous en parallèle dès WT3 mergé)
|
||||
WT4 1.6 processor base: develop ≥ WT3
|
||||
WT5 1.4 qualimat endpoint base: develop ≥ WT2 (perm) + ERP-39 (indépendant de WT3)
|
||||
WT6 1.7 adresses base: develop ≥ WT3
|
||||
WT7 1.8 contacts base: develop ≥ WT3
|
||||
WT8 1.9 prix base: develop ≥ WT3
|
||||
WT9 1.10 export XLSX base: develop ≥ WT3
|
||||
|
||||
VAGUE 3 (final)
|
||||
WT10 1.11 tests + fixtures + contrat base: develop ≥ TOUT
|
||||
```
|
||||
|
||||
**Parallélisme réel** : 2 worktrees en V0, puis 1 goulot (WT3), puis **jusqu'à 6 en V2**, puis 1 (WT10).
|
||||
|
||||
## Règle anti-conflit worktree (IMPORTANT)
|
||||
|
||||
Pour que WT4→WT9 tournent en parallèle sans conflit de merge :
|
||||
|
||||
| Fichier partagé | Qui le touche | Les autres |
|
||||
|---|---|---|
|
||||
| `CarrierFixtures` | **WT10 uniquement** | interdit (WT3 met un fixture minimal, WT6-9 n'y touchent pas) |
|
||||
| Entité `Carrier` (ApiResource) | **WT3** crée, **WT4** ajoute le Processor | WT6-9 créent des **resources/processors dédiés** par sous-entité, ne modifient pas `Carrier` |
|
||||
| `ColumnCommentsCatalog` | WT1 (`uploaded_document`), WT3 (`carrier*`) | personne d'autre |
|
||||
| `fr.json` (clés audit) | **WT3** (clés `audit.entity.transport_*`) | personne d'autre côté back |
|
||||
| `migrations/` | WT1 puis WT3 (ordre timestamp) | aucune autre migration |
|
||||
|
||||
## Mode retenu : STACK séquentiel, SANS worktree (repo principal)
|
||||
|
||||
Matthieu empile les MR, un ticket à la fois, **directement dans `/home/matthieu/dev_malio/Starseed`** (pas de worktree).
|
||||
- **Ignorer les blocs `git worktree add` des `WT*.md`** → remplacés par une branche normale :
|
||||
```bash
|
||||
git fetch origin
|
||||
git checkout -b feat/erp-XXX-... origin/<branche-précédente>
|
||||
```
|
||||
- **WT1 hors pile** (déjà mergé). Pile M4 — chaque branche basée sur la précédente :
|
||||
`WT2 → WT3 → WT4 → WT5 → WT6 → WT7 → WT8 → WT9 → WT10`
|
||||
- PR de chaque maillon : `--base <branche-précédente>` (bas de pile WT2 = `develop`). Au merge, les MR du dessus se recible auto.
|
||||
- Docker tourne sur le repo principal → `make test`/`php-cs-fixer` OK sans rebind (le piège worktree-vs-mount ne s'applique plus).
|
||||
- Worktrees créés pour WT1/WT2 à nettoyer : `git worktree remove ../sb-erp154-upload ../sb-erp153-rbac`.
|
||||
- Garder les MR basses propres ; merger dans l'ordre.
|
||||
|
||||
## Suivi (tenu par la conv maître)
|
||||
|
||||
| WT | Ticket | ERP | État | PR | Notes |
|
||||
|----|--------|-----|------|----|----|
|
||||
| WT1 | 1.2 upload | 154 | ✅ MERGÉ | #108 | migration `Version20260615130000` |
|
||||
| WT2 | 1.1 RBAC | 153 | ✅ PR ouverte | #111 | bas de pile (cible develop) |
|
||||
| WT3 | 1.3+1.5 | 155+157 | ▶️ À LANCER | — | stack sur `feat/erp-153-rbac` ; gate contrat front |
|
||||
| WT4 | 1.6 proc | 158 | ⛔ bloqué par WT3 | — | |
|
||||
| WT5 | 1.4 qualimat | 156 | ⛔ bloqué par WT2+ERP-39 | — | |
|
||||
| WT6 | 1.7 adresses | 159 | ⛔ bloqué par WT3 | — | |
|
||||
| WT7 | 1.8 contacts | 160 | ⛔ bloqué par WT3 | — | |
|
||||
| WT8 | 1.9 prix | 161 | ⛔ bloqué par WT3 | — | |
|
||||
| WT9 | 1.10 export | 162 | ⛔ bloqué par WT3 | — | |
|
||||
| WT10 | 1.11 tests | 163 | ⛔ bloqué par tout | — | |
|
||||
|
||||
## Cadre commun à tous les prompts (rappels projet)
|
||||
|
||||
- Carrier vit dans `src/Module/Transport/` (créé par ERP-150). **Miroir = `src/Module/Commercial/`** (Supplier).
|
||||
- Tests sous `tests/Module/Transport/Api/` (miroir `tests/Module/Commercial/Api/`).
|
||||
- `declare(strict_types=1);` partout ; commentaires **FR**, code EN.
|
||||
- `make test` + `make php-cs-fixer-allow-risky` avant de dire « fini ».
|
||||
- Ne jamais mentionner Claude/IA dans commit/PR.
|
||||
@@ -0,0 +1,44 @@
|
||||
# WT1 — Infra upload générique `Shared` (ticket 1.2 / ERP-154)
|
||||
|
||||
> Créer le worktree puis lancer Claude dedans :
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp154-upload -b feat/erp-154-upload origin/develop
|
||||
> cd ../sb-erp154-upload && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` (aucune dépendance — peut démarrer tout de suite, même avant le merge du socle Transport).
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Tu travailles sur le projet Starseed (modular monolith DDD, Symfony 8 / API Platform 4). Lis `CLAUDE.md` et `.claude/rules/backend.md` avant de coder. Charge le skill `backend-entity-conventions`.
|
||||
|
||||
**Mission** : poser une infra d'upload de fichiers **générique et réutilisable** dans `src/Shared/` (la « Décharge » du M4 en sera le 1er consommateur, mais ce ticket ne touche PAS au module Transport).
|
||||
|
||||
**Spec** : `docs/specs/M4-transporteurs/spec-back.md § 2.7`.
|
||||
|
||||
**À livrer** :
|
||||
1. Table `uploaded_document` (migration namespace racine `DoctrineMigrations` dans `migrations/`, postérieure à la dernière présente — vérifie `ls migrations/`). Colonnes : `id`, `original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum`, `created_at`, `created_by`.
|
||||
2. Service `Shared\Infrastructure\Upload\FileUploader` :
|
||||
- validation MIME **server-side via `$file->getMimeType()`** (JAMAIS `getClientMimeType()`),
|
||||
- whitelist MIME explicite (PDF + images),
|
||||
- bornage taille, checksum sha256, écriture disque `var/uploads/{yyyy}/{mm}/`.
|
||||
3. Endpoint `POST /api/uploaded_documents` (multipart) → renvoie l'IRI. MIME hors whitelist → **422**.
|
||||
|
||||
**Gardes-fous (cassent `make test` sinon)** :
|
||||
- **`COMMENT ON COLUMN` sur TOUTES les colonnes** de `uploaded_document` (FR, ≤200 car., règle n°12) ET ajoute le bloc `'uploaded_document' => [...]` dans `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php` — sinon `make test-db-setup` drope les COMMENT et `ColumnsHaveSqlCommentTest` casse.
|
||||
- Pagination : si tu exposes une `GetCollection`, elle reste paginée (`CollectionsArePaginatedTest`).
|
||||
|
||||
**Scope STRICT** : uniquement `src/Shared/` + migration + catalog. Ne crée AUCUN fichier sous `src/Module/Transport/`. Pas d'antivirus/S3/purge (hors périmètre, § 9).
|
||||
|
||||
**Tests à écrire** (PHPUnit) : MIME hors whitelist → 422 ; MIME valide → IRI + ligne persistée + checksum calculé.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky` propre. Commit (`--no-verify` OK si `make test` déjà vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-154-upload
|
||||
tea pr create --base develop --head feat/erp-154-upload \
|
||||
--title "feat(shared) : infra upload générique (ERP-154)" \
|
||||
--description "Table uploaded_document + FileUploader + endpoint POST. Ticket ERP-154."
|
||||
```
|
||||
Puis labellise la PR via l'API Gitea (tea ne pose pas les labels en CLI). Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,37 @@
|
||||
# WT10 — Tests PHPUnit + fixtures + contrat JSON (ticket 1.11 / ERP-163)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp163-tests -b feat/erp-163-carrier-tests origin/develop
|
||||
> cd ../sb-erp163-tests && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de TOUS les worktrees back** (WT1→WT9). C'est le filet final.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`, `.claude/rules/testing.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `tests/Module/Commercial/Api/Supplier*Test.php`.
|
||||
|
||||
**Mission** : couverture complète des RG + capture du contrat de sérialisation + fixtures consolidées. C'est le DoD back avant intégration front.
|
||||
|
||||
**Spec** : `spec-back.md § 4.0.bis / 8.1 / 8.4`.
|
||||
|
||||
**À livrer** :
|
||||
- Matrice **RG-4.01→4.14** couverte (§ 8.1) + RBAC par rôle (Compta/Usine → 403, Commerciale → 403 sur write, Admin → archive).
|
||||
- `CarrierSerializationContractTest` : capture JSON réel **liste + détail** ; `prices[].client`/`.supplier`/sites **embarqués** (pas IRI) ; `qualimatCarrier` embarqué ; `isArchived` présent. Colle les JSON dans `spec-back.md § 4.0.bis`.
|
||||
- Anti-N+1 liste ; pagination Hydra ; audit (`entity_type='Carrier'`) ; `AuditableEntitiesHaveI18nLabelTest` vert.
|
||||
- **`CarrierFixtures` idempotent (§ 8.4)** — c'est ICI que les fixtures complètes vivent : transporteur QUALIMAT (validité passée → RG-4.04), AUTRE+décharge, affrété, LIOT, complet (contacts/adresses/prix CLIENT+FOURNISSEUR), 1 archivé.
|
||||
|
||||
**Piège CI (mémoire projet)** : la CI tourne `APP_DEBUG=0`. Les tests de **comptage de requêtes (anti-N+1)** passent en local mais cassent en CI (DoctrineDataHolder absent) → vérifie/active `profiling: true` dans la config Doctrine de l'environnement `test`. Sans ça le test anti-N+1 sera rouge en CI.
|
||||
|
||||
**Scope** : tests + `CarrierFixtures` + remplissage § 4.0.bis. Tu peux ajuster un test cassé hérité d'un autre WT mais signale-le à la conv maître (ne masque pas un vrai bug).
|
||||
|
||||
**Fini quand** : `make test` **intégralement vert** + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-163-carrier-tests
|
||||
tea pr create --base develop --head feat/erp-163-carrier-tests \
|
||||
--title "test(transport) : couverture RG-4.01→4.14 + contrat + fixtures (ERP-163)" \
|
||||
--description "Matrice RG + CarrierSerializationContractTest + CarrierFixtures + § 4.0.bis. Ticket ERP-163."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,45 @@
|
||||
# WT2 — Permissions `transport.carriers.*` + sidebar (ticket 1.1 / ERP-153)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp153-rbac -b feat/erp-153-rbac origin/develop
|
||||
> cd ../sb-erp153-rbac && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge d'ERP-150** (le module `Transport` doit exister). Vérifie : `ls src/Module/Transport/`.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (modular monolith DDD). Lis `CLAUDE.md`, `.claude/rules/architecture.md` et `.claude/rules/testing.md` avant de coder.
|
||||
|
||||
**Mission** : poser le socle RBAC du module Transport et son entrée de menu. `TransportModule::permissions()` renvoie `[]` aujourd'hui.
|
||||
|
||||
**Spec** : `spec-back.md § 5` + `spec-front.md § Accès`.
|
||||
|
||||
**À livrer** :
|
||||
1. `TransportModule::permissions()` déclare `transport.carriers.view`, `transport.carriers.manage`, `transport.carriers.archive`. `app:sync-permissions` les enregistre.
|
||||
2. **Matrice § 5.2** : Admin (view+manage+archive), Bureau (view+manage), Commerciale (view), Compta + Usine (**aucune**).
|
||||
3. **RÈGLE ABSOLUE n°8 — les 3 sources RBAC dans le MÊME commit** :
|
||||
- `config/sidebar.php` : section « Transport » + item `/carriers` + `permission: transport.carriers.view`,
|
||||
- `frontend/tests/e2e/_fixtures/personas.ts` : ajuster `permissions` + `expectedAdminLinks` des personas existants,
|
||||
- `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` : miroir back des mêmes personas.
|
||||
4. Item sidebar masqué pour Compta/Usine ; visible Admin/Bureau/Commerciale.
|
||||
|
||||
**Pièges** :
|
||||
- Ne touche QUE le RBAC/sidebar — pas d'entité, pas de migration.
|
||||
- Toute modif d'une seule des 3 sources sans les 2 autres = drift / test cassé.
|
||||
- Section « Transport » vs « Logistique » : prends « Transport » (cosmétique, alignable plus tard).
|
||||
|
||||
**Tests à écrire/vérifier** : `app:sync-permissions` OK ; cohérence personas (pas de drift). Lance `make test`.
|
||||
|
||||
**Scope STRICT** : RBAC + sidebar + 3 miroirs. Rien d'autre.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si test vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-153-rbac
|
||||
tea pr create --base develop --head feat/erp-153-rbac \
|
||||
--title "feat(transport) : permissions carriers + sidebar (ERP-153)" \
|
||||
--description "RBAC transport.carriers.* + 3 sources RBAC alignées. Ticket ERP-153."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,54 @@
|
||||
# WT3 ⭐ — Migration + entités Carrier* + ApiResource + Provider (tickets 1.3 + 1.5 / ERP-155 + ERP-157)
|
||||
|
||||
> **Worktree pivot : il livre le CONTRAT JSON qui débloque tout le front.**
|
||||
> **Mode STACK, sans worktree** (repo principal) — base = branche de WT2 :
|
||||
> ```bash
|
||||
> cd /home/matthieu/dev_malio/Starseed && git fetch origin
|
||||
> git checkout -b feat/erp-155-carrier-schema-entities origin/feat/erp-153-rbac
|
||||
> ```
|
||||
> **Base** : `feat/erp-153-rbac` (contient ERP-150 + WT1 + RBAC WT2). Quand #111 sera mergé dans develop, la PR de WT3 se recible automatiquement sur develop.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (modular monolith DDD, Symfony 8 / API Platform 4). Lis `CLAUDE.md`, `.claude/rules/backend.md`, `.claude/rules/architecture.md`. **Charge le skill `backend-entity-conventions`** (patterns entités/migrations complets).
|
||||
|
||||
**Mission** : créer le schéma BDD du répertoire transporteurs + les entités + le contrat de lecture (liste + détail). Tu poses le contrat JSON sur lequel le front s'appuiera — c'est le livrable critique.
|
||||
|
||||
**Spec** : `spec-back.md § 3.2 / 3.3 / 3.4 / 4.0 / 4.1 / 4.2`. **Miroir = le module Supplier** : `src/Module/Commercial/Domain/Entity/Supplier*.php`, `…/Infrastructure/ApiPlatform/State/Provider/SupplierProvider.php`, `…/Serializer/SupplierReadGroupContextBuilder.php`. Carrier vit dans `src/Module/Transport/`.
|
||||
|
||||
### Étape A — Migration (`migrations/`, namespace racine `DoctrineMigrations`)
|
||||
- **PAS de migration modulaire** : même si la spec dit « modulaire », toute migration va dans `migrations/` namespace racine (tri FQCN cassant sinon). Postérieure à la dernière présente — vérifie `ls migrations/` (à ce jour `Version20260615120000`).
|
||||
- Tables `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` + FK : `qualimat_carrier`, `uploaded_document`, `client`, `client_address`, `supplier`, `supplier_address`, `site`, `user`.
|
||||
- `certification_type` **nullable** (null en cas LIOT) + CHECK enum ; CHECK sur `container_type`, `direction`, `pricing_unit`, `price_state`, branches Prix client/fournisseur.
|
||||
- Index partiel `uq_carrier_name_active` : `LOWER(name)` WHERE non archivé ET non supprimé.
|
||||
- **`COMMENT ON COLUMN` sur TOUTES les colonnes** (FR, ≤200 car.) + helper standard pour les 4 colonnes Timestampable/Blamable. Bonus `COMMENT ON TABLE`.
|
||||
|
||||
### Étape B — Entités + repos
|
||||
- `Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice` : `#[Auditable]`, `implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait`. Repos `*RepositoryInterface` (Domain) + `Doctrine*Repository` (Infrastructure).
|
||||
- `ApiResource` Carrier (attribut sur l'entité, comme Supplier) : `GetCollection` + `Get` + `Post` + `Patch` avec `security` (§ 3.3). **PAS de Delete**.
|
||||
- Groupes : `carrier:read`, `carrier:item:read`, `qualimat:read`. **Embed au détail** (pas IRI) : `client:read`/`client_address:read`/`supplier:read`/`supplier_address:read`/`site:read` + `qualimatCarrier`. ⚠ les adresses de l'onglet Prix sont des `ClientAddress`/`SupplierAddress` distinctes.
|
||||
- `CarrierProvider` paginé (`ApiPlatform\Doctrine\Orm\Paginator`), liste **sans cloisonnement site** (§ 2.3), **anti-N+1** (fetch joins, § 2.11), exclut les archivés par défaut + `?includeArchived=true`.
|
||||
- Piège booléen : `#[SerializedName('isArchived')]` sur le getter.
|
||||
|
||||
### Gardes-fous qui CASSENT `make test` (à traiter dans CE worktree)
|
||||
- `ColumnsHaveSqlCommentTest` → COMMENT partout **+ ajouter les blocs `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` dans `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php`** (sinon `test-db-setup` drope les COMMENT).
|
||||
- `makefile test-db-setup` : l'index partiel `uq_carrier_name_active` n'est PAS exprimé par `schema:update` → **ajoute-le à la ligne `dbal:run-sql` du target `test-db-setup`** du `makefile`, sinon `make test` casse.
|
||||
- `AuditableEntitiesHaveI18nLabelTest` → ajoute dans `frontend/i18n/locales/fr.json` les clés `audit.entity.transport_carrier`, `transport_carrieraddress`, `transport_carriercontact`, `transport_carrierprice` (clé = strtolower(module)+'_'+strtolower(Entity)).
|
||||
- `EntitiesAreTimestampableBlamableTest`, `EntityConstraintsHaveFrenchMessageTest` (messages FR + `Length.max` = longueur colonne), `CollectionsArePaginatedTest`.
|
||||
|
||||
**Scope STRICT** : schéma + entités + ApiResource lecture + Provider + i18n audit. **PAS** le Processor d'écriture (→ WT4), **PAS** les sous-ressources POST/PATCH adresses/contacts/prix (→ WT6/7/8), **PAS** l'export (→ WT9). Mets un `CarrierFixtures` **minimal** (1-2 lignes) juste pour faire tourner tes tests de lecture ; les fixtures complètes sont faites par WT10 — n'y investis pas.
|
||||
|
||||
**Tests à écrire** : liste exclut archivés / `?includeArchived=true` ; enveloppe Hydra (`member`/`totalItems`) ; `isArchived` présent dans le JSON ; embeds détail présents (pas IRI).
|
||||
|
||||
**LIVRABLE GATE** : une fois vert, **capture le JSON réel liste + détail** (`curl` ou test) et colle-le dans `spec-back.md § 4.0.bis`. C'est le signal pour démarrer le front. Préviens la conv maître.
|
||||
|
||||
**Fini quand** : `make db-reset` OK + `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si test vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-155-carrier-schema-entities
|
||||
tea pr create --base feat/erp-153-rbac --head feat/erp-155-carrier-schema-entities \
|
||||
--title "feat(transport) : schéma + entités Carrier + contrat lecture (ERP-155/157)" \
|
||||
--description "Migration + entités Carrier* + ApiResource lecture + Provider + i18n audit + contrat JSON. Tickets ERP-155, ERP-157."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,41 @@
|
||||
# WT4 — CarrierProcessor (ticket 1.6 / ERP-158)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp158-processor -b feat/erp-158-carrier-processor origin/develop
|
||||
> cd ../sb-erp158-processor && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de WT3** (entités Carrier) **et WT1** (upload, pour la décharge).
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
|
||||
|
||||
**Mission** : logique d'écriture du formulaire principal Carrier (POST/PATCH) — normalisation, champs conditionnels, archivage. **Miroir** : `src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php` + `Application/Service/SupplierFieldNormalizer.php`.
|
||||
|
||||
**Spec** : `spec-back.md § 4.3 / 4.4 / 7`.
|
||||
|
||||
**Règles métier à implémenter (un test PHPUnit par RG)** :
|
||||
- **RG-4.01** : POST avec `qualimatCarrier` → `certificationType=QUALIMAT` + FK persistée ; cas LIOT (`name='LIOT'`) ⇒ `certificationType` non requis, `liotPlates` accepté.
|
||||
- **RG-4.02** : `certificationType='AUTRE'` sans `dischargeDocument` → **422** (`#[Assert\Callback]`).
|
||||
- **RG-4.03** : `isChartered=true` sans `indexationRate` / `containerType` / `volumeM3` → **422**.
|
||||
- **RG-4.13** : normalisation via `CarrierFieldNormalizer` (miroir Supplier) — `name` UPPER, contacts Capitalize, phones digits-only, email lower, `liotPlates` (`;`-split/trim/UPPER).
|
||||
- **RG-4.12** : doublon `name` (parmi actifs) → **409** + `setError` ciblé.
|
||||
- **RG-4.14** : PATCH `isArchived` exige `transport.carriers.archive` (Admin) ; mode strict → 403 sinon.
|
||||
|
||||
**Pièges** :
|
||||
- Messages de validation **FR explicites** sur chaque contrainte (`EntityConstraintsHaveFrenchMessageTest`).
|
||||
- Le back renvoie **toutes** les violations d'un coup avec `propertyPath` aligné sur les champs front.
|
||||
|
||||
**Scope STRICT** : `CarrierProcessor` + `CarrierFieldNormalizer` + contraintes sur l'entité `Carrier` (formulaire principal). **NE TOUCHE PAS** : les sous-ressources adresses/contacts/prix (WT6/7/8), `CarrierFixtures` (WT10), l'export (WT9). Ajoute tes contraintes sur `Carrier` sans réécrire l'ApiResource posée par WT3.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-158-carrier-processor
|
||||
tea pr create --base develop --head feat/erp-158-carrier-processor \
|
||||
--title "feat(transport) : CarrierProcessor (RG-4.01→4.03/4.12→4.14) (ERP-158)" \
|
||||
--description "Normalisation + champs conditionnels + archive. Ticket ERP-158."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,37 @@
|
||||
# WT5 — Endpoint QualimatCarrier lecture seule (ticket 1.4 / ERP-156)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp156-qualimat -b feat/erp-156-qualimat-search origin/develop
|
||||
> cd ../sb-erp156-qualimat && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de WT2** (permission `transport.carriers.view`) **et ERP-39** (table `qualimat_carrier` peuplée). **Indépendant de WT3** — peut tourner en parallèle.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
|
||||
|
||||
**Mission** : exposer le référentiel QUALIMAT (table existante `qualimat_carrier`, alimentée par console) en **lecture seule** + endpoint de recherche pour la saisie assistée du nom (RG-4.01). **Ne touche pas** la commande de sync.
|
||||
|
||||
**Spec** : `spec-back.md § 4.7` + RG-4.01.
|
||||
|
||||
**À livrer** :
|
||||
1. Entité `QualimatCarrier` (lecture seule) mappée sur la table existante `qualimat_carrier`. **Aucune écriture exposée** (pas de Post/Patch/Delete). Probablement pas `#[Auditable]` ni Timestampable (référentiel externe synchronisé) — vérifie le mapping existant.
|
||||
2. `GET /api/qualimat_carriers?search=` : fuzzy sur `name` (+ `siret`), **seulement `is_active = true`**, tri `name`, **paginé** (règle n°13 — `CollectionsArePaginatedTest`).
|
||||
3. **Security** `is_granted('transport.carriers.view')`.
|
||||
4. Champs exposés : `id, siret, name, address, postalCode, city, phone, department, status, validityDate, isActive`.
|
||||
|
||||
**Tests à écrire** : recherche ne renvoie que les actifs ; pagination Hydra ; 403 sans permission ; tri `name`.
|
||||
|
||||
**Scope STRICT** : uniquement l'exposition lecture de `qualimat_carrier`. Ne crée rien autour de `Carrier` (autres worktrees). Si la table n'a pas de COMMENT (référentiel pré-existant), vérifie si elle est dans `EXCLUDED_TABLES` de `ColumnsHaveSqlCommentTest` — ne casse pas ce test.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-156-qualimat-search
|
||||
tea pr create --base develop --head feat/erp-156-qualimat-search \
|
||||
--title "feat(transport) : endpoint recherche QualimatCarrier (ERP-156)" \
|
||||
--description "Entité lecture seule + GET /api/qualimat_carriers?search=. Ticket ERP-156."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,37 @@
|
||||
# WT6 — Sous-ressource Adresses (ticket 1.7 / ERP-159)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp159-adresses -b feat/erp-159-carrier-addresses origin/develop
|
||||
> cd ../sb-erp159-adresses && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de WT3** (entités `CarrierAddress`). Parallèle à WT5/WT7/WT8/WT9.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `SupplierAddressProcessor.php` (`src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/`).
|
||||
|
||||
**Mission** : opérations d'écriture sur les adresses transporteur.
|
||||
|
||||
**Spec** : `spec-back.md § 4.5` + RG-4.05→4.07.
|
||||
|
||||
**À livrer** :
|
||||
- `POST /api/carriers/{id}/addresses`, `PATCH`/`DELETE /api/carrier_addresses/{id}` (security `manage`) — **resource/processor dédiés à `CarrierAddress`**, ne modifie pas l'ApiResource `Carrier`.
|
||||
- **RG-4.06** : `postalCode` matche `^[0-9]{4,5}$` (autocomplete ville = front). Message FR.
|
||||
- **RG-4.05** : si affrété → adresse obligatoire (Pays/CP/Ville/Adresse) — validation conditionnelle.
|
||||
- RG-4.07 (bouton Valider masqué si QUALIMAT) = front ; côté back, accepter le PATCH normalement.
|
||||
|
||||
**Tests à écrire** : CP invalide → 422 ; adresse affrété incomplète → 422 ; PATCH/DELETE OK avec `manage`, 403 sans.
|
||||
|
||||
**Scope STRICT** : uniquement `CarrierAddress` (resource + processor + tests). **NE TOUCHE PAS** `CarrierFixtures` (WT10), l'entité `Carrier`, les autres sous-ressources. Messages de validation FR (`EntityConstraintsHaveFrenchMessageTest`).
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-159-carrier-addresses
|
||||
tea pr create --base develop --head feat/erp-159-carrier-addresses \
|
||||
--title "feat(transport) : sous-ressource adresses transporteur (ERP-159)" \
|
||||
--description "POST/PATCH/DELETE carrier_address + RG-4.05→4.07. Ticket ERP-159."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,35 @@
|
||||
# WT7 — Sous-ressource Contacts (ticket 1.8 / ERP-160)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp160-contacts -b feat/erp-160-carrier-contacts origin/develop
|
||||
> cd ../sb-erp160-contacts && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de WT3**. Parallèle à WT5/WT6/WT8/WT9.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `SupplierContactProcessor.php` (`src/Module/Commercial/…/State/Processor/`).
|
||||
|
||||
**Mission** : opérations d'écriture sur les contacts transporteur.
|
||||
|
||||
**Spec** : `spec-back.md § 4.5` + RG-4.08.
|
||||
|
||||
**À livrer** :
|
||||
- `POST /api/carriers/{id}/contacts`, `PATCH`/`DELETE /api/carrier_contacts/{id}` (security `manage`) — resource/processor dédiés à `CarrierContact`.
|
||||
- **RG-4.08** : bloc valide si **≥ 1 champ rempli** (CHECK `chk_carrier_contact_filled` côté migration WT3 + validation Processor) ; **max 2 téléphones**.
|
||||
|
||||
**Tests à écrire** : contact vide → 422 ; 1 champ → 200/201 ; 3ᵉ téléphone → 422.
|
||||
|
||||
**Scope STRICT** : uniquement `CarrierContact`. **NE TOUCHE PAS** `CarrierFixtures` (WT10), `Carrier`, les autres sous-ressources. Messages FR. Si le CHECK `chk_carrier_contact_filled` manque (WT3 ne l'a pas posé), valide côté Processor et signale-le à la conv maître.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-160-carrier-contacts
|
||||
tea pr create --base develop --head feat/erp-160-carrier-contacts \
|
||||
--title "feat(transport) : sous-ressource contacts transporteur (ERP-160)" \
|
||||
--description "POST/PATCH/DELETE carrier_contact + RG-4.08 (≥1 champ, max 2 tel). Ticket ERP-160."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,39 @@
|
||||
# WT8 — Sous-ressource Prix + RG branches (ticket 1.9 / ERP-161)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp161-prix -b feat/erp-161-carrier-prices origin/develop
|
||||
> cd ../sb-erp161-prix && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de WT3**. Parallèle à WT5/WT6/WT7/WT9.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
|
||||
|
||||
**Mission** : opérations d'écriture sur les prix transporteur, avec branches Client / Fournisseur.
|
||||
|
||||
**Spec** : `spec-back.md § 4.5 / 7` + RG-4.09→4.11.
|
||||
|
||||
**À livrer** :
|
||||
- `POST /api/carriers/{id}/prices`, `PATCH`/`DELETE /api/carrier_prices/{id}` (security `manage`) — resource/processor dédiés à `CarrierPrice`.
|
||||
- **RG-4.10 (CLIENT)** : `client`, `clientDeliveryAddress`, `departureSite` requis ; `clientDeliveryAddress` **doit appartenir au `client`** → sinon 422.
|
||||
- **RG-4.11 (FOURNISSEUR)** : `supplier`, `supplierSupplyAddress`, `deliverySite` requis ; `supplierSupplyAddress` appartient au `supplier` → sinon 422.
|
||||
- Communs obligatoires : `containerType`, `pricingUnit`, `price`, `priceState`. CHECK branches respectés.
|
||||
|
||||
**Rappels FK** : « Adresse départ/livraison 86/17/82 » = `Site` (FK). Livraison client = `ClientAddress`, appro = `SupplierAddress` (relations ORM partagées — pas de M2M).
|
||||
|
||||
**Tests à écrire** : branche CLIENT/FOURNISSEUR incomplète → 422 ; adresse étrangère au client/supplier → 422 ; prix valide → 201.
|
||||
|
||||
**Scope STRICT** : uniquement `CarrierPrice`. **NE TOUCHE PAS** `CarrierFixtures` (WT10), `Carrier`, les autres sous-ressources. Messages FR.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-161-carrier-prices
|
||||
tea pr create --base develop --head feat/erp-161-carrier-prices \
|
||||
--title "feat(transport) : sous-ressource prix transporteur (ERP-161)" \
|
||||
--description "POST/PATCH/DELETE carrier_price + RG-4.09→4.11 (branches client/fournisseur). Ticket ERP-161."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,36 @@
|
||||
# WT9 — Export XLSX (ticket 1.10 / ERP-162)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp162-export -b feat/erp-162-carrier-export origin/develop
|
||||
> cd ../sb-erp162-export && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de WT3** (lecture Carrier). Parallèle à WT5/WT6/WT7/WT8.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. **Miroir** : `src/Module/Commercial/Infrastructure/Controller/SupplierExportController.php` (PhpSpreadsheet déjà présent).
|
||||
|
||||
**Mission** : export Excel du répertoire et du tableau Prix regroupé.
|
||||
|
||||
**Spec** : `spec-back.md § 4.6`.
|
||||
|
||||
**À livrer** :
|
||||
- `GET /api/carriers/export.xlsx` : transporteurs affichés (**mêmes filtres** que la liste) ; colonnes § 4.6.
|
||||
- `GET /api/carriers/{id}/prices/export.xlsx` : tableau Prix regroupé Benne / Fond Mouvant (colonnes docx p.10).
|
||||
- **Controllers custom** avec `#[Route(priority: 1)]` (sinon conflit API Platform `{id}`) ; en-tête `Content-Disposition`.
|
||||
|
||||
**Tests à écrire** : 200 + en-tête fichier (Content-Disposition + type XLSX) ; respect des filtres.
|
||||
|
||||
**Scope STRICT** : controllers d'export + service de génération. **NE TOUCHE PAS** entités, processors, `CarrierFixtures` (WT10). Réutilise le Provider/filtres de WT3 pour la cohérence des données exportées.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-162-carrier-export
|
||||
tea pr create --base develop --head feat/erp-162-carrier-export \
|
||||
--title "feat(transport) : export XLSX répertoire + prix (ERP-162)" \
|
||||
--description "GET /api/carriers/export.xlsx + /carriers/{id}/prices/export.xlsx. Ticket ERP-162."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,354 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M4
|
||||
nom: "Répertoire transporteurs"
|
||||
ecran: repertoire-transporteurs
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0.1
|
||||
date_redaction: 2026-06-15
|
||||
# Historique :
|
||||
# V0.1 (2026-06-15) — Restitution Markdown du docx « M4-repertoire-transporteurs-V0 »
|
||||
# (validé 27/05/2026) + maquette Figma (node 1132-45376). Précisions techniques (back)
|
||||
# dans spec-back.md. Réutilise le pattern et les composants M1/M2/M3.
|
||||
|
||||
# === LIENS ===
|
||||
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-45376&p=f&m=dev"
|
||||
regles_metier: [RG-4.01, RG-4.02, RG-4.03, RG-4.04, RG-4.05, RG-4.06, RG-4.07, RG-4.08, RG-4.09, RG-4.10, RG-4.11]
|
||||
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||
lien_spec_back: ./spec-back.md
|
||||
|
||||
# === VALIDATION CLIENT ===
|
||||
client_validation_1:
|
||||
statut: validee
|
||||
date: 2026-05-27
|
||||
version: V0
|
||||
valide_par: "Matthieu (CP MALIO)"
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_project_id: 6
|
||||
lesstime_taskgroup_id: 31 # M4 — Répertoire transporteurs (tickets ERP-153 → ERP-171)
|
||||
statut_global: pret_a_dev
|
||||
---
|
||||
|
||||
# Module 4 — Répertoire transporteurs (V0.1 front)
|
||||
|
||||
> **Origine** : spec fonctionnelle `M4-repertoire-transporteurs-V0` (validée le 27/05/2026) + maquette Figma. Restitution Markdown pour intégration au workflow MALIO. Toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M4 réutilise le pattern et les composants posés aux [M1 clients](../M1-clients/spec-front.md), [M2 fournisseurs](../M2-suppliers/spec-front.md) et [M3 prestataires](../M3-prestataires/spec-front.md).
|
||||
|
||||
> **Socle déjà en place** : le module back `Transport` existe (ERP-150) et porte deux référentiels **synchronisés par commandes console** : transporteurs **QUALIMAT** (`qualimat_carrier`, ERP-39) et codes **IDTF** (`idtf_product`, ERP-149). Le M4 ajoute le **répertoire éditable** (`Carrier`) **par-dessus** ces référentiels — la saisie assistée du nom interroge le référentiel QUALIMAT (RG-4.01). L'IDTF n'est **pas** utilisé par ces écrans.
|
||||
|
||||
> **Décisions Matthieu (15/06/2026)** : (1) lien QUALIMAT = FK + **copie éditable** des champs (nom / certification / adresse) ; (2) **pas de cloisonnement par site** (référentiel global) ; (3) le champ « Décharge » s'appuie sur une **infra d'upload réutilisable** (`Shared`), car d'autres uploads suivront. Détails : [`spec-back.md § 2.5 / § 2.3 / § 2.7`](./spec-back.md).
|
||||
|
||||
## But
|
||||
|
||||
Lister tous les transporteurs de l'organisation et accéder rapidement à leurs fiches : consultation, création, modification, archivage. Le nom est **relié à QUALIMAT** (saisie assistée) ; les transporteurs hors QUALIMAT (GMP+, OVOCOM, compte-propre, LIOT, autre) sont saisis manuellement.
|
||||
|
||||
## Accès
|
||||
|
||||
- **Depuis** : menu principal → section **Transport** (route `/carriers`). *(Section « Transport » dédiée ou rattachement à une section « Logistique » — à confirmer, cf. [`spec-back.md § 5.3`](./spec-back.md).)*
|
||||
- **Rôles autorisés** (tableau « Rôles & permissions » du docx) :
|
||||
|
||||
| Rôle | Consultation | Ajout / Modification | Archive |
|
||||
|---|---|---|---|
|
||||
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
|
||||
| **Bureau** | ✅ Tout | ✅ Tout | ❌ |
|
||||
| **Compta** | ❌ | ❌ | ❌ |
|
||||
| **Commerciale** | ✅ Tout | ❌ | ❌ |
|
||||
| **Usine** | ❌ | ❌ | ❌ |
|
||||
|
||||
> **Notes** :
|
||||
> - RBAC transposée sur `transport.carriers.*` (cf. [`spec-back.md § 5`](./spec-back.md)). **Commerciale** = consultation seule (pas de « + Ajouter » ni « Modifier »). **Compta** et **Usine** n'ont **aucun** accès au module (item sidebar masqué).
|
||||
> - **Pas de cloisonnement par site** (≠ M3) : tout rôle autorisé voit tous les transporteurs.
|
||||
|
||||
## Navigation
|
||||
|
||||
Page d'entrée du module **Transport** (route `/carriers`). Titre : « **Répertoire transporteurs** ».
|
||||
|
||||
- Affichage principal : un **datatable** listant tous les transporteurs **actifs** (les archivés sont masqués par défaut — filtre dédié).
|
||||
- **Clic sur une ligne** → écran **Consultation transporteur** (page dédiée).
|
||||
- **Bouton « + Ajouter »** (haut droite, si `manage`) → écran **Ajouter un transporteur**.
|
||||
- **Bouton « Filtrer »** (haut droite) → panneau de filtres.
|
||||
- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des transporteurs **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md).
|
||||
|
||||
### Panneau de filtres (bouton « Filtrer »)
|
||||
|
||||
Réutilise le pattern M1/M2/M3. Filtres branchés sur les query params de `GET /api/carriers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) :
|
||||
|
||||
| Filtre | Composant | Query param back |
|
||||
|---|---|---|
|
||||
| **Recherche** (nom) | `<MalioInputText>` | `?search=` |
|
||||
| **Certification** | `<MalioSelectCheckbox>` (QUALIMAT / GMP+ / OVOCOM / Compte-propre / Autre) | `?certificationType=` |
|
||||
| **Inclure les archivés** | `<MalioCheckbox>` | `?includeArchived=true` |
|
||||
|
||||
- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**).
|
||||
- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6).
|
||||
|
||||
## Datatable du Répertoire
|
||||
|
||||
Composant : `<MalioDataTable>` branché sur `usePaginatedList<Carrier>({ url: '/carriers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes :
|
||||
|
||||
| Colonne | Source | Tri |
|
||||
|---|---|---|
|
||||
| **Nom** | `carrier.name` | ASC par défaut |
|
||||
| **Certification** | `carrier.certificationType` (libellé i18n) | Non |
|
||||
| **Date de validité** | `carrier.qualimatCarrier.validityDate` (format `JJ-MM-AAAA`) — **fond rouge si < aujourd'hui** (RG-4.04) | Non |
|
||||
| **Dernière activité** | `carrier.updatedAt` (format `JJ-MM-AAAA`) | Oui |
|
||||
|
||||
> **Clic sur une ligne** → écran Consultation. **Pagination** : standard Starseed 10 / 25 / 50 (défaut 10). Tri serveur `name ASC` par défaut.
|
||||
|
||||
## Écran « Ajouter un transporteur »
|
||||
|
||||
Création par **onglets successifs avec validation incrémentale** : pour passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant** ; les champs validés passent en lecture seule. **L'onglet Adresses n'est accessible qu'une fois le formulaire principal validé.** Cf. [`spec-back.md § 2.9`](./spec-back.md) (PATCH partiels par groupe de sérialisation).
|
||||
|
||||
**Accès** : bouton « + Ajouter » du Répertoire. **Rôles** : Admin, Bureau.
|
||||
|
||||
**Barre d'onglets** : `Qualimat` · `Adresses` · `Contacts` · `Prix`.
|
||||
|
||||
### Formulaire principal (pré-onglets)
|
||||
|
||||
1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/carriers`, puis bascule sur l'onglet Qualimat/Adresses ; les champs passent en readonly.
|
||||
|
||||
| Champ | Type composant | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom** (saisie assistée reliée à QUALIMAT) | `<MalioInputText>` (autocomplete) | Oui | RG-4.01 ; RG-4.13 (UPPERCASE serveur) ; RG-4.12 (unicité) |
|
||||
| **Liste certification transport** | `<MalioSelect>` (GMP+ / OVOCOM / Compte-propre / Autre) | Oui | RG-4.02 ; auto = `QUALIMAT` (lecture seule) si transporteur QUALIMAT sélectionné |
|
||||
| **Affréter** | `<MalioCheckbox>` | Non | RG-4.03 |
|
||||
| **Indexation %** | `<MalioInputNumber>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
|
||||
| **Benne / Fond mouvant** | `<MalioRadioButton>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
|
||||
| **Volume m³** | `<MalioInputNumber>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
|
||||
| **Décharge** | `<MalioInputUpload>` *(cf. note)* | Conditionnel (**obligatoire si AUTRE**) | RG-4.02 — visible **et obligatoire** si certification = `AUTRE`. Upload via infra Shared ([`spec-back.md § 2.7`](./spec-back.md)) |
|
||||
| **Liste immatriculation LIOT** | `<MalioInputText>` (ou TextArea) | Cas LIOT | RG-4.01 — visible **uniquement** si nom = `LIOT` ; les autres champs disparaissent. Immatriculations séparées par `;` |
|
||||
|
||||
> **Comportement RG-4.01 (saisie assistée)** : à la saisie du nom, recherche dans le référentiel QUALIMAT via `GET /api/qualimat_carriers?search=`. Sélection d'un résultat → **modal de confirmation** « Êtes-vous sûr de vouloir intégrer ce transporteur ? ». Si confirmé : le **Nom** et la **certification** (= `QUALIMAT`, lecture seule) se remplissent automatiquement, **ainsi que l'onglet Adresse** (copie pays/CP/ville/voie depuis le référentiel). La FK QUALIMAT est conservée (traçabilité + date de validité RG-4.04).
|
||||
> - **Cas transporteur non trouvé** (pas QUALIMAT) : l'utilisateur choisit une autre certification (RG-4.02) → affichage des champs associés.
|
||||
> - **Cas LIOT** : si le nom saisi est exactement `LIOT`, seul le champ « Liste immatriculation LIOT » s'affiche, les autres champs sont masqués.
|
||||
|
||||
> **Note `<MalioInputUpload>`** : si le composant ne couvre pas le drag & drop / type fichier requis, exception autorisée documentée (`// TODO migrer quand Malio couvre`) — cf. exceptions @.claude/rules/frontend.md.
|
||||
|
||||
**Action** : « Valider » (`<MalioButton>`) → POST `/api/carriers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Qualimat » / « Adresses ».
|
||||
|
||||
### Onglet « Qualimat »
|
||||
|
||||
Sélectionner un transporteur de la liste QUALIMAT afin de mettre à jour les informations du transporteur (saisie assistée — voir RG-4.01).
|
||||
|
||||
**Colonnes du tableau de sélection** :
|
||||
|
||||
| Colonne | Règle |
|
||||
|---|---|
|
||||
| **Sélection** (bouton / clic ligne) | RG-4.03 *(docx)* — clic → modal « Êtes-vous sûr de vouloir intégrer ce transporteur ? » → remplit Nom + certification + onglet adresse |
|
||||
| **Nom** | — |
|
||||
| **Adresse** | — |
|
||||
| **Date de validité** | RG-4.04 — **fond rouge si < date du jour** |
|
||||
|
||||
> Cet onglet alimente le formulaire principal et l'onglet Adresse par copie (RG-4.01 / RG-4.05). Source : `GET /api/qualimat_carriers?search=` (lecture seule, lignes actives uniquement).
|
||||
|
||||
### Onglet « Adresses »
|
||||
|
||||
Saisir l'adresse du transporteur (un bloc par adresse).
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Pays** | `<MalioSelect>` (préremplie « France ») | Conditionnel | RG-4.05 |
|
||||
| **Code postal** | `<MalioInputText>` (saisie assistée) | Conditionnel | RG-4.06, RG-4.05 — déclenche autocomplete ville (BAN) |
|
||||
| **Ville** | `<MalioSelect>` (saisie assistée) | Conditionnel | RG-4.06, RG-4.05 — alimentée par api-adresse.data.gouv.fr |
|
||||
| **Adresse** | `<MalioInputText>` (saisie assistée) | Conditionnel | RG-4.05 |
|
||||
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
|
||||
|
||||
> **RG-4.05** : les champs sont **déjà remplis** si le transporteur est QUALIMAT (copie). Si « Affréter » est coché, l'adresse devient **obligatoire** (Pays, Code postal, Ville, Adresse).
|
||||
> **RG-4.06** : la ville est préremplie automatiquement à partir du code postal via l'API BAN (`useAddressAutocomplete()`, réutilisé M1/M2/M3). Si plusieurs villes → choix dans le select. L'adresse est une saisie assistée basée sur le CP et la ville.
|
||||
> **RG-4.07** : le bouton « Valider » **n'apparaît pas** pour un transporteur QUALIMAT (adresse remplie automatiquement).
|
||||
|
||||
**Actions** : « Valider » → PATCH `/api/carriers/{id}/addresses` (sauf QUALIMAT, RG-4.07).
|
||||
|
||||
### Onglet « Contacts »
|
||||
|
||||
Saisir un ou plusieurs contacts associés au transporteur.
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom** | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (Capitalize) |
|
||||
| **Prénom** | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (Capitalize) |
|
||||
| **Fonction** | `<MalioInputText>` | Non | RG-4.08 |
|
||||
| **Téléphone** (x1, +1 possible, **max 2**) | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (format) |
|
||||
| **Email** | `<MalioInputText>` type email | Non | RG-4.08 + RG-4.13 (lowercase) |
|
||||
|
||||
**RG-4.08** : un bloc Contact est valide dès qu'au moins 1 champ est rempli. Impossible d'ajouter un nouveau bloc tant que le précédent n'est pas valide.
|
||||
|
||||
**Actions** :
|
||||
- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a aucun champ rempli** (RG-4.08).
|
||||
- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc.
|
||||
- « Valider » → PATCH `/api/carriers/{id}/contacts`.
|
||||
|
||||
### Onglet « Prix »
|
||||
|
||||
Saisir un suivi de prix du transporteur (un bloc par prix). Tous les champs sont masqués par défaut sauf le radio « Client / Fournisseur » (RG-4.09).
|
||||
|
||||
**Bloc Prix** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Client / Fournisseur** | `<MalioRadioButton>` | Oui | RG-4.09 |
|
||||
| **Client** | `<MalioSelect>` (liste des clients) | Conditionnel | RG-4.10 — si Client |
|
||||
| **Adresse de livraison** | `<MalioSelect>` (adresses du client sélectionné) | Conditionnel | RG-4.10 — si Client |
|
||||
| **Adresse de départ** | `<MalioSelect>` (86 / 17 / 82) | Conditionnel | RG-4.10 — si Client ; = un des 3 sites |
|
||||
| **Fournisseur** | `<MalioSelect>` (liste des fournisseurs) | Conditionnel | RG-4.11 — si Fournisseur |
|
||||
| **Adresse d'approvisionnement** | `<MalioSelect>` (adresses du fournisseur) | Conditionnel | RG-4.11 — si Fournisseur |
|
||||
| **Adresse de livraison** | `<MalioSelect>` (86 / 17 / 82) | Conditionnel | RG-4.11 — si Fournisseur ; = un des 3 sites |
|
||||
| **Benne / Fond mouvant (FM)** | `<MalioRadioButton>` | Oui | — |
|
||||
| **Forfait / Tonne** | `<MalioRadioButton>` | Oui | — |
|
||||
| **Prix** | `<MalioInputAmount>` (monnaie) | Oui | — |
|
||||
| **État du prix** | `<MalioSelect>` (En cours / Validé / Non validé) | Oui | — |
|
||||
|
||||
> **RG-4.10** : si **Client** sélectionné → champs liés au client affichés et obligatoires ; champs fournisseur masqués et non obligatoires.
|
||||
> **RG-4.11** : si **Fournisseur** sélectionné → champs liés au fournisseur affichés et obligatoires ; champs client masqués et non obligatoires.
|
||||
> **Adresse de départ / livraison « 86 / 17 / 82 »** = les 3 `Site` fixes (cf. switcher de site Châtellerault / Saint-Jean / Pommevic en haut de l'app). La sélection stocke un **ID de Site** ([`spec-back.md § 3.2`](./spec-back.md)).
|
||||
|
||||
**Actions** :
|
||||
- « + Nouveau prix » : ajoute un bloc. Bloqué tant que le précédent n'est pas valide.
|
||||
- « Supprimer » (icône) : modal de confirmation puis suppression.
|
||||
- « Valider » → PATCH `/api/carriers/{id}/prices`.
|
||||
|
||||
## Écran « Consultation d'un transporteur »
|
||||
|
||||
Consulter en **lecture seule** la fiche complète. Affiche en haut du bloc les infos principales du transporteur (comme l'écran d'ajout) ainsi que les onglets Adresses, Contacts, Prix. **Tous les champs sont en lecture seule.**
|
||||
|
||||
**Accès** : clic sur une ligne du Répertoire. La page s'ouvre par défaut sur l'onglet **Adresses**. Icône « flèche » à gauche pour revenir au répertoire. Deux boutons à droite :
|
||||
- **« Modifier »** (visible si `transport.carriers.manage` → Admin, Bureau).
|
||||
- **« Archiver »** (visible **uniquement Admin** via `transport.carriers.archive`) → modal de confirmation, puis PATCH `/api/carriers/{id}` `{ "isArchived": true }`.
|
||||
|
||||
> Un transporteur archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé.
|
||||
|
||||
### Onglet Adresses (consultation)
|
||||
|
||||
Un bloc par adresse du transporteur. Chaque bloc, 5 champs en lecture seule : Pays / Code postal / Ville / Adresse / Adresse complémentaire.
|
||||
|
||||
### Onglet Contacts (consultation)
|
||||
|
||||
Un bloc par contact. 5 champs en lecture seule : Nom / Prénom / Fonction / Téléphone (x1 ou x2) / Email.
|
||||
|
||||
### Onglet Prix (consultation)
|
||||
|
||||
Un tableau regroupant les prix par type (**Fond Mouvant / Benne**) :
|
||||
|
||||
| Colonne | Description |
|
||||
|---|---|
|
||||
| **Colonne de regroupement** | « Fond Mouvant » / « Benne » |
|
||||
| **Transporteurs** | Nom du transporteur |
|
||||
| **Adresse APRO ou Adresse Sites** | Si prix « Client » → Adresse APRO sinon Adresse Sites |
|
||||
| **Adresse livraisons** | — |
|
||||
| **Forfait €** | Prix |
|
||||
| **Tonne €** | Prix |
|
||||
| **Indexation** | Pourcentage d'indexation (vide si non rempli) |
|
||||
| **État du prix** | Validé / Non Validé / En cours |
|
||||
|
||||
**Action** : « Exporter » → exporte le tableau au **format Excel** (`GET /api/carriers/{id}/prices/export.xlsx`).
|
||||
|
||||
## Écran « Modification d'un transporteur »
|
||||
|
||||
Modifier les informations d'un transporteur existant. **Identique à l'écran « Ajouter un transporteur »** — mêmes formulaires, mêmes règles métier (RG-4.01 à RG-4.11) — sauf :
|
||||
- Les champs sont **pré-remplis** avec les valeurs actuelles.
|
||||
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
|
||||
- **Accès** : depuis l'écran Consultation, bouton « Modifier » (Admin, Bureau).
|
||||
|
||||
## Composants UI à utiliser (`@malio/layer-ui`)
|
||||
|
||||
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
|
||||
- **Input texte** : `<MalioInputText>`
|
||||
- **Input nombre / montant** : `<MalioInputNumber>` (indexation, volume), `<MalioInputAmount>` (prix)
|
||||
- **Select simple** : `<MalioSelect>` (certification, pays, ville, client, fournisseur, adresses, sites, état du prix)
|
||||
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (filtres certification)
|
||||
- **Radio** : `<MalioRadioButton>` (Benne/Fond mouvant, Forfait/Tonne, Client/Fournisseur)
|
||||
- **Checkbox** : `<MalioCheckbox>` (Affréter, inclure archivés)
|
||||
- **Upload** : `<MalioInputUpload>` (Décharge — exception documentée si type non couvert)
|
||||
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
|
||||
- **Toasts** : standards via `useApi()`
|
||||
- **Validation par champ** : `useFormErrors` (mapping 422 inline — règle frontend obligatoire)
|
||||
|
||||
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
|
||||
- Modal de confirmation : wrapper partagé dans `frontend/shared/` (réutiliser celui du M1/M2/M3).
|
||||
- `<MalioInputUpload>` si le type fichier / drag & drop n'est pas couvert.
|
||||
|
||||
## Composables & appels API
|
||||
|
||||
- `usePaginatedList<Carrier>({ url: '/carriers' })` — liste paginée (obligatoire). Consomme `name`, `certificationType`, `qualimatCarrier.validityDate` (RG-4.04), `updatedAt` (cf. [`spec-back.md § 2.11 / § 4.0`](./spec-back.md)).
|
||||
- `useCarrier(id)` — charge le détail via `GET /api/carriers/{id}`, qui **embarque** `addresses`, `contacts`, `prices` (avec `client`/`supplier`/sites imbriqués) + `qualimatCarrier`. Écrans Consultation et Modification peuplés depuis cette seule réponse. **DoD avant intégration** : vérifier le JSON réel (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)).
|
||||
- `useCarrierForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useSupplierForm()`/`useProviderForm()` + gestion des **champs conditionnels** (Affréter, AUTRE→Décharge, cas LIOT).
|
||||
- `useQualimatSearch()` — saisie assistée du nom : `GET /api/qualimat_carriers?search=`, modal de confirmation, copie des champs + FK (RG-4.01).
|
||||
- `useAddressAutocomplete()` — **réutilisé** du M1/M2/M3 (BAN), pas de réécriture (RG-4.06).
|
||||
- `useUpload()` (NOUVEAU, infra Shared) — POST multipart `/api/uploaded_documents` → renvoie l'IRI à poser sur `carrier.dischargeDocument` (RG-4.02).
|
||||
- `usePermissions()` — masque l'item sidebar et les boutons selon les permissions.
|
||||
- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4).
|
||||
- Filter `formatPhoneFR()` — **réutilisé** pour l'affichage `XX XX XX XX XX`.
|
||||
|
||||
## Règles de formatage et normalisation
|
||||
|
||||
Le serveur normalise systématiquement (RG-4.13 — cf. [`spec-back.md`](./spec-back.md)) :
|
||||
|
||||
| Champ | Normalisation serveur | Affichage front |
|
||||
|---|---|---|
|
||||
| Nom transporteur (`name`) | UPPERCASE intégral | UPPERCASE |
|
||||
| Nom + Prénom contact | Capitalize | identique |
|
||||
| Téléphones (`CarrierContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` (filter Vue) |
|
||||
| Email | lowercase intégral | identique |
|
||||
| Immatriculations LIOT | `;`-split, trim, UPPER | listées |
|
||||
|
||||
> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur normalisée que l'UI affiche.
|
||||
|
||||
## API adresse postale
|
||||
|
||||
Code postal + Ville + Adresse branchés sur **api-adresse.data.gouv.fr** (BAN) via le composable `useAddressAutocomplete()` **déjà créé au M1/M2/M3** (réutilisé tel quel) :
|
||||
- À la saisie du CP (5 chiffres) : `GET https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville (RG-4.06 : si plusieurs villes, choix dans le select).
|
||||
- À la saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions.
|
||||
- Cas dégradé (timeout / offline) : Ville en `<MalioInputText>` libre + toast d'avertissement.
|
||||
|
||||
## Différences notables avec les modules précédents
|
||||
|
||||
| Zone | M2/M3 | M4 transporteurs |
|
||||
|---|---|---|
|
||||
| Source du nom | saisie libre | **saisie assistée reliée à QUALIMAT** (référentiel synchronisé) |
|
||||
| Onglet Comptabilité / RIB | présent (M2/M3) | **Absent** |
|
||||
| Cloisonnement par site | M3 : oui | **Non** (référentiel global) |
|
||||
| Champs conditionnels formulaire principal | peu | **Nombreux** (Affréter, AUTRE→Décharge, cas LIOT) |
|
||||
| Onglet Prix | absent | **Présent** (Client/Fournisseur, sites départ/livraison) |
|
||||
| Upload de fichier | aucun | **Décharge** (infra upload Shared, réutilisable) |
|
||||
| Module | Commercial / Technique | **Transport** (existant, ERP-150) |
|
||||
|
||||
## Points résolus côté back
|
||||
|
||||
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|
||||
|---|---|---|
|
||||
| 1 | Lien QUALIMAT | FK `qualimatCarrier` + **copie éditable** des champs (§ 2.5) |
|
||||
| 2 | Cas LIOT | Champ `liotPlates` (`;`-séparé), autres champs masqués (RG-4.01) |
|
||||
| 3 | Certification QUALIMAT | Valeur `QUALIMAT` lecture seule si lié (§ 2.5) |
|
||||
| 4 | Décharge (upload) | Infra upload générique `Shared` réutilisable (§ 2.7) |
|
||||
| 5 | Onglet Prix — branches | M2M absentes : FK Client/Supplier + adresses + sites (RG-4.10/4.11, § 3.2) |
|
||||
| 6 | Adresse de départ/livraison 86/17/82 | = les 3 `Site` fixes (FK Site) |
|
||||
| 7 | Workflow par onglet | Sauvegarde incrémentale (POST principal + PATCH partiels) — pas d'état « draft » |
|
||||
| 8 | Archive vs delete | Flag `is_archived` séparé ; archivage Admin seul ; soft delete = HP |
|
||||
| 9 | Unicité métier | Nom seul (§ 2.6) |
|
||||
| 10 | Référentiel QUALIMAT | Endpoint lecture seule `GET /api/qualimat_carriers?search=` (§ 4.7) |
|
||||
| 11 | Format export | XLSX (répertoire + onglet Prix regroupé Benne/FM) |
|
||||
| 12 | RBAC | `transport.carriers.view/manage/archive` ; Compta + Usine sans accès (§ 5.2) |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tickets Lesstime
|
||||
|
||||
**TaskGroup Lesstime** : à créer — `M4 — Répertoire transporteurs` (projet `ERP / Starseed`, projectId=6). Découpe détaillée (back en tête) → [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper).
|
||||
|
||||
| Ordre | Sujet | Tag |
|
||||
|---|---|---|
|
||||
| 0 | Permissions `transport.carriers.*` + sidebar + 3 sources RBAC | Backend |
|
||||
| 1 | Infra upload générique `Shared` (uploaded_document + FileUploader + endpoint) | Backend |
|
||||
| 2 | Migration BDD M4 (carrier + sous-collections + index + COMMENT) | Backend |
|
||||
| 3 | Entité `QualimatCarrier` (lecture seule) + endpoint recherche | Backend |
|
||||
| 4 | Entités + Repositories Carrier* | Backend |
|
||||
| 5 | CarrierProvider + CarrierProcessor (champs conditionnels, archive, LIOT) | Backend |
|
||||
| 6 | Sous-ressources Adresses / Contacts / Prix (RG-4.10/4.11) | Backend |
|
||||
| 7 | Export XLSX (répertoire + onglet Prix) | Backend |
|
||||
| 8 | Tests PHPUnit RG-4.01→4.14 + capture contrat JSON | Backend |
|
||||
| 9 | Page Répertoire (`/carriers`) + usePaginatedList | Frontend |
|
||||
| 10 | Page Ajouter + formulaire principal + saisie assistée QUALIMAT | Frontend |
|
||||
| 11 | Onglets Adresses (BAN) / Contacts / Prix | Frontend |
|
||||
| 12 | Pages Consultation + Modification | Frontend |
|
||||
| 13 | i18n + libellés audit + upload front (useUpload) | Frontend |
|
||||
@@ -0,0 +1,307 @@
|
||||
# M4 — Répertoire transporteurs · Découpe en tickets Lesstime
|
||||
|
||||
> **Statut** : ✅ **poussé dans Lesstime** — TaskGroup **#31 « M4 — Répertoire transporteurs »** (projet STARSEED), 19 tickets **ERP-153 → ERP-171** au statut **Prêt à dev**.
|
||||
> **Assignation** : tickets **Backend (1.1→1.11, ERP-153→163) → Matthieu** · tickets **Frontend (1.12→1.19, ERP-164→171) → Tristan**.
|
||||
>
|
||||
> | Pos | Ticket | Réf |
|
||||
> |---|---|---|
|
||||
> | 1.1 | Permissions transport.carriers.* + sidebar | ERP-153 |
|
||||
> | 1.2 | Infra upload générique Shared | ERP-154 |
|
||||
> | 1.3 | Migration BDD M4 | ERP-155 |
|
||||
> | 1.4 | QualimatCarrier + endpoint recherche | ERP-156 |
|
||||
> | 1.5 | Entités Carrier* + ApiResource + Provider | ERP-157 |
|
||||
> | 1.6 | CarrierProcessor (RG-4.01/02/03 + LIOT) | ERP-158 |
|
||||
> | 1.7 | Sous-ressource Adresses | ERP-159 |
|
||||
> | 1.8 | Sous-ressource Contacts | ERP-160 |
|
||||
> | 1.9 | Sous-ressource Prix + branches | ERP-161 |
|
||||
> | 1.10 | Export XLSX | ERP-162 |
|
||||
> | 1.11 | Tests PHPUnit + contrat JSON | ERP-163 |
|
||||
> | 1.12 | Page Répertoire /carriers | ERP-164 |
|
||||
> | 1.13 | Page Ajouter (layout + formulaire) | ERP-165 |
|
||||
> | 1.14 | Saisie assistée QUALIMAT + conditionnels | ERP-166 |
|
||||
> | 1.15 | Onglet Adresses (BAN) | ERP-167 |
|
||||
> | 1.16 | Onglet Contacts | ERP-168 |
|
||||
> | 1.17 | Onglet Prix | ERP-169 |
|
||||
> | 1.18 | Consultation + Modification | ERP-170 |
|
||||
> | 1.19 | Upload front + i18n + audit | ERP-171 |
|
||||
> **Specs sources** : [`spec-back.md`](./spec-back.md) · [`spec-front.md`](./spec-front.md) — validées (docx V0 du 27/05/2026).
|
||||
> **Maquette Figma** : node `1132-45376` ([lien](https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-45376&p=f&m=dev)).
|
||||
|
||||
## ⚠️ Dépendance amont (socle Tristan — en cours de merge)
|
||||
|
||||
Le M4 s'appuie sur le module `Transport` et le référentiel QUALIMAT, livrés par les PR de Tristan **en cours de merge** dans `develop` :
|
||||
|
||||
- **ERP-150** (PR #97) — module `Transport` (`TransportModule`, layer front, `config/modules.php`). **Requis** par tout le M4.
|
||||
- **ERP-39** (PR #99) — sync QUALIMAT (`qualimat_carrier` + commande `app:qualimat:sync`). **Requis** par la saisie assistée (ticket 1.4).
|
||||
- **ERP-149** (PR #101) — sync IDTF (`idtf_product`). **NON requis** par le M4 (référentiel autonome, hors écrans transporteurs).
|
||||
|
||||
> Les 3 PR sont **empilées** (`develop → ERP-150 → ERP-39 → ERP-149`). Démarrer le M4 une fois **ERP-150 + ERP-39 dans `develop`** (DoR des tickets 1.1 et 1.4). Brancher le M4 sur `develop` post-merge.
|
||||
|
||||
## Vue d'ensemble (ordre d'exécution)
|
||||
|
||||
| # | Ticket | Tag | Effort | RG / dépend |
|
||||
|---|---|---|---|---|
|
||||
| 1.1 | Déclarer permissions `transport.carriers.*` + sidebar | Backend | S | DoR : ERP-150 mergé |
|
||||
| 1.2 | Créer l'infra d'upload générique `Shared` | Backend | M | § 2.7 |
|
||||
| 1.3 | Migrer le schéma BDD M4 (carrier + sous-tables) | Backend | M | § 3.2 |
|
||||
| 1.4 | Exposer `QualimatCarrier` (lecture seule) + endpoint recherche | Backend | S | RG-4.01 · DoR : ERP-39 mergé |
|
||||
| 1.5 | Créer entités `Carrier*` + repos + `ApiResource` + `CarrierProvider` | Backend | M | § 3.3 / 4.0 |
|
||||
| 1.6 | Implémenter `CarrierProcessor` (RG-4.01/4.02/4.03 + LIOT + normalisation + archive) | Backend | M | RG-4.01→4.03, 4.13, 4.14 |
|
||||
| 1.7 | Sous-ressource Adresses (`carrier_address`) | Backend | S | RG-4.05→4.07 |
|
||||
| 1.8 | Sous-ressource Contacts (`carrier_contact`) | Backend | S | RG-4.08 |
|
||||
| 1.9 | Sous-ressource Prix (`carrier_price`) + RG branches | Backend | M | RG-4.09→4.11 |
|
||||
| 1.10 | Export XLSX (répertoire + onglet Prix regroupé) | Backend | M | § 4.6 |
|
||||
| 1.11 | Tests PHPUnit RG-4.01→4.14 + capture contrat JSON (DoD) | Backend | M | § 4.0.bis / 8.1 |
|
||||
| 1.12 | Page Répertoire `/carriers` (datatable, filtres, export) | Frontend | M | RG-4.04 |
|
||||
| 1.13 | Page Ajouter `/carriers/new` (layout, onglets, formulaire principal POST) | Frontend | M | RG-4.12 |
|
||||
| 1.14 | Saisie assistée QUALIMAT + champs conditionnels (Affréter / AUTRE→Décharge / LIOT) | Frontend | M | RG-4.01→4.03 |
|
||||
| 1.15 | Onglet Adresses (autocomplete BAN) | Frontend | M | RG-4.05→4.07 |
|
||||
| 1.16 | Onglet Contacts | Frontend | S | RG-4.08 |
|
||||
| 1.17 | Onglet Prix (Client/Fournisseur, sites) | Frontend | M | RG-4.09→4.11 |
|
||||
| 1.18 | Pages Consultation + Modification | Frontend | M | — |
|
||||
| 1.19 | Upload front (`useUpload`) + i18n + libellés audit | Frontend | S | § 2.8 |
|
||||
|
||||
**Total** : 19 tickets · ~11 back / 8 front · mini-MR de 1 à 4h.
|
||||
|
||||
---
|
||||
|
||||
## Tickets — détail
|
||||
|
||||
### 1.1 — Déclarer permissions `transport.carriers.*` + sidebar
|
||||
**Position** : 1.1 • Suit : — • Précède : Migrer le schéma BDD M4
|
||||
**Tag** : Backend • **Effort** : S
|
||||
**Contexte** : `TransportModule::permissions()` renvoie aujourd'hui `[]`. Ce ticket pose le socle RBAC du module et son entrée de menu, prérequis de toute opération sécurisée.
|
||||
**Spec liée** : [`spec-back.md § 5`](./spec-back.md) · [`spec-front.md § Accès`](./spec-front.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] `TransportModule::permissions()` déclare `transport.carriers.view`, `transport.carriers.manage`, `transport.carriers.archive` ; `app:sync-permissions` les enregistre.
|
||||
- [ ] **Matrice § 5.2** : Admin (view+manage+archive), Bureau (view+manage), Commerciale (view), Compta + Usine (aucune).
|
||||
- [ ] **3 sources RBAC alignées dans le même commit** (règle ABSOLUE n°8) : `config/sidebar.php` (section Transport + item `/carriers` + permission), `personas.ts`, `SeedE2ECommand.php`.
|
||||
- [ ] Item sidebar masqué pour Compta/Usine ; visible Admin/Bureau/Commerciale.
|
||||
**Tests à prévoir** : permissions sync OK ; personas e2e cohérents (pas de drift).
|
||||
**Tips** : DoR — ERP-150 mergé (module Transport présent). Section sidebar « Transport » (ou « Logistique » — à trancher, cosmétique).
|
||||
|
||||
### 1.2 — Créer l'infra d'upload générique `Shared`
|
||||
**Position** : 1.2 • Suit : permissions • Précède : Migration M4
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Contexte** : la « Décharge » (RG-4.02) est le 1er d'une série d'uploads à venir. On pose une infra réutilisable, pas un upload ad hoc.
|
||||
**Spec liée** : [`spec-back.md § 2.7`](./spec-back.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Table `uploaded_document` (`original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum`, `created_at`, `created_by`) + COMMENT ON COLUMN.
|
||||
- [ ] Service `Shared\Infrastructure\Upload\FileUploader` : validation MIME **server-side via `$file->getMimeType()`** (jamais `getClientMimeType()`), bornage taille, checksum sha256, écriture disque (`var/uploads/{yyyy}/{mm}/`).
|
||||
- [ ] Endpoint `POST /api/uploaded_documents` (multipart) → renvoie l'IRI ; whitelist MIME explicite (PDF + images) ; hors whitelist → 422.
|
||||
**Tests à prévoir** : PHPUnit — MIME hors whitelist → 422 ; MIME valide → IRI + ligne persistée ; checksum calculé.
|
||||
**Tips** : générique et réutilisable (autres modules la consommeront). Antivirus / S3 / purge = HP (§ 9).
|
||||
|
||||
### 1.3 — Migrer le schéma BDD M4 (carrier + sous-tables)
|
||||
**Position** : 1.3 • Suit : infra upload • Précède : QualimatCarrier
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Contexte** : créer le schéma du répertoire (entité éditable distincte du référentiel `qualimat_carrier`).
|
||||
**Spec liée** : [`spec-back.md § 3.2`](./spec-back.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Migration namespace racine `DoctrineMigrations`, **postérieure** à `Version20260612160000`.
|
||||
- [ ] Tables `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` + FK (`qualimat_carrier`, `uploaded_document`, `client`, `client_address`, `supplier`, `supplier_address`, `site`, `user`).
|
||||
- [ ] `certification_type` **nullable** (null seulement en cas LIOT) + CHECK enum ; CHECK `container_type`, `direction`, `pricing_unit`, `price_state`, branches Prix client/fournisseur.
|
||||
- [ ] Index partiel `uq_carrier_name_active` (LOWER(name), WHERE non archivé & non supprimé).
|
||||
- [ ] **`COMMENT ON COLUMN` sur TOUTES les colonnes** (règle n°12) + helper Timestampable/Blamable. `ColumnsHaveSqlCommentTest` vert.
|
||||
- [ ] `make db-reset` passe ; schéma conforme.
|
||||
**Tests à prévoir** : `make db-reset` OK ; `ColumnsHaveSqlCommentTest` vert ; index partiel présent.
|
||||
**Tips** : PK `BIGINT` (cohérence module Transport) — à confirmer vs `INT`.
|
||||
|
||||
### 1.4 — Exposer `QualimatCarrier` (lecture seule) + endpoint recherche
|
||||
**Position** : 1.4 • Suit : migration • Précède : entités Carrier*
|
||||
**Tag** : Backend • **Effort** : S
|
||||
**Contexte** : la saisie assistée du nom (RG-4.01) a besoin d'un endpoint de recherche sur le référentiel QUALIMAT, aujourd'hui alimenté en console mais non exposé.
|
||||
**Spec liée** : [`spec-back.md § 4.7`](./spec-back.md) · RG-4.01
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Entité `QualimatCarrier` (lecture seule) mappée sur la table existante `qualimat_carrier` (aucune écriture exposée).
|
||||
- [ ] `GET /api/qualimat_carriers?search=` : fuzzy sur `name` (+ `siret`), **seulement `is_active = true`**, tri `name`, paginé (règle n°13).
|
||||
- [ ] **Security** `is_granted('transport.carriers.view')`. Champs exposés : `id, siret, name, address, postalCode, city, phone, department, status, validityDate, isActive`.
|
||||
**Tests à prévoir** : PHPUnit — recherche ne renvoie que les actifs ; pagination Hydra ; 403 sans permission.
|
||||
**Tips** : DoR — ERP-39 mergé. Ne pas toucher la commande de sync.
|
||||
|
||||
### 1.5 — Créer entités `Carrier*` + repos + `ApiResource` + `CarrierProvider`
|
||||
**Position** : 1.5 • Suit : QualimatCarrier • Précède : CarrierProcessor
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Contexte** : poser les entités, le contrat de sérialisation (groupes) et la lecture (liste + détail).
|
||||
**Spec liée** : [`spec-back.md § 3.3 / 3.4 / 4.0 / 4.1 / 4.2`](./spec-back.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Entités `Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice` (`#[Auditable]`, `TimestampableBlamableTrait`), repos Doctrine.
|
||||
- [ ] `ApiResource` Carrier : `GetCollection` + `Get` + `Post` + `Patch` avec `security` (§ 3.3) ; **pas de Delete**.
|
||||
- [ ] Groupes de sérialisation : `carrier:read`, `carrier:item:read`, `qualimat:read`, embed `client:read`/`client_address:read`/`supplier:read`/`supplier_address:read`/`site:read` au détail (3 maillons § 4.0 — ⚠ les adresses de l'onglet Prix sont des entités `ClientAddress`/`SupplierAddress` distinctes).
|
||||
- [ ] `CarrierProvider` paginé (`ApiPlatform\Doctrine\Orm\Paginator`) ; liste **sans cloisonnement site** (§ 2.3) ; anti-N+1 (§ 2.11).
|
||||
- [ ] Piège booléen `isArchived` : `#[SerializedName('isArchived')]` sur le getter.
|
||||
**Tests à prévoir** : liste exclut archivés par défaut ; `?includeArchived=true` ; enveloppe Hydra ; `isArchived` présent dans le JSON.
|
||||
**Tips** : miroir `Supplier`/`Provider`. Pas d'onglet Comptabilité (≠ M2/M3).
|
||||
|
||||
### 1.6 — Implémenter `CarrierProcessor`
|
||||
**Position** : 1.6 • Suit : entités • Précède : sous-ressource Adresses
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Contexte** : logique d'écriture du formulaire principal (POST/PATCH) : normalisation, champs conditionnels, archivage.
|
||||
**Spec liée** : [`spec-back.md § 4.3 / 4.4 / 7`](./spec-back.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] **RG-4.01** : POST avec `qualimatCarrier` → `certificationType=QUALIMAT` + FK persistée ; cas LIOT : `name='LIOT'` ⇒ `certificationType` non requis, `liotPlates` accepté.
|
||||
- [ ] **RG-4.02** : `certificationType='AUTRE'` sans `dischargeDocument` → **422** (`#[Assert\Callback]`).
|
||||
- [ ] **RG-4.03** : `isChartered=true` sans `indexationRate`/`containerType`/`volumeM3` → **422**.
|
||||
- [ ] **RG-4.13** : normalisation (`name` UPPER, contacts Capitalize, phones digits, email lower, `liotPlates`).
|
||||
- [ ] **RG-4.12** : doublon `name` (actifs) → **409**.
|
||||
- [ ] **RG-4.14** : PATCH `isArchived` exige `transport.carriers.archive` (Admin) ; mode strict (403 sinon).
|
||||
**Tests à prévoir** : PHPUnit sur chaque RG ci-dessus (cf. § 8.1).
|
||||
**Tips** : `CarrierFieldNormalizer` miroir `SupplierFieldNormalizer`.
|
||||
|
||||
### 1.7 — Sous-ressource Adresses (`carrier_address`)
|
||||
**Position** : 1.7 • Suit : CarrierProcessor • Précède : Contacts
|
||||
**Tag** : Backend • **Effort** : S
|
||||
**Spec liée** : [`spec-back.md § 4.5`](./spec-back.md) · RG-4.05→4.07
|
||||
**Critères d'acceptation** :
|
||||
- [ ] `POST /api/carriers/{id}/addresses`, `PATCH`/`DELETE /api/carrier_addresses/{id}` (security `manage`).
|
||||
- [ ] **RG-4.06** : `postalCode` matche `^[0-9]{4,5}$` (autocomplete ville = front).
|
||||
- [ ] **RG-4.05** : si affrété, adresse obligatoire (Pays/CP/Ville/Adresse) — validation conditionnelle.
|
||||
**Tests à prévoir** : PHPUnit — CP invalide → 422 ; adresse affrété incomplète → 422.
|
||||
**Tips** : RG-4.07 (bouton Valider masqué si QUALIMAT) = front, back accepte le PATCH.
|
||||
|
||||
### 1.8 — Sous-ressource Contacts (`carrier_contact`)
|
||||
**Position** : 1.8 • Suit : Adresses • Précède : Prix
|
||||
**Tag** : Backend • **Effort** : S
|
||||
**Spec liée** : [`spec-back.md § 4.5`](./spec-back.md) · RG-4.08
|
||||
**Critères d'acceptation** :
|
||||
- [ ] `POST /api/carriers/{id}/contacts`, `PATCH`/`DELETE /api/carrier_contacts/{id}` (security `manage`).
|
||||
- [ ] **RG-4.08** : bloc valide si ≥ 1 champ rempli (CHECK `chk_carrier_contact_filled` + Processor) ; **max 2 téléphones**.
|
||||
**Tests à prévoir** : PHPUnit — contact vide → 422 ; 1 champ → 200.
|
||||
**Tips** : miroir contacts M2/M3.
|
||||
|
||||
### 1.9 — Sous-ressource Prix (`carrier_price`) + RG branches
|
||||
**Position** : 1.9 • Suit : Contacts • Précède : Export
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Spec liée** : [`spec-back.md § 4.5 / 7`](./spec-back.md) · RG-4.09→4.11
|
||||
**Critères d'acceptation** :
|
||||
- [ ] `POST /api/carriers/{id}/prices`, `PATCH`/`DELETE /api/carrier_prices/{id}` (security `manage`).
|
||||
- [ ] **RG-4.10** (CLIENT) : `client`, `clientDeliveryAddress`, `departureSite` requis ; `clientDeliveryAddress` doit appartenir au `client` → sinon 422.
|
||||
- [ ] **RG-4.11** (FOURNISSEUR) : `supplier`, `supplierSupplyAddress`, `deliverySite` requis ; `supplierSupplyAddress` appartient au `supplier` → sinon 422.
|
||||
- [ ] Communs obligatoires : `containerType`, `pricingUnit`, `price`, `priceState` ; CHECK branches respectées.
|
||||
**Tests à prévoir** : PHPUnit — branche CLIENT/FOURNISSEUR incomplète → 422 ; adresse étrangère → 422.
|
||||
**Tips** : « Adresse départ/livraison 86/17/82 » = `Site` (FK) ; livraison client = `ClientAddress`, appro = `SupplierAddress` (relations ORM partagées).
|
||||
|
||||
### 1.10 — Export XLSX (répertoire + onglet Prix regroupé)
|
||||
**Position** : 1.10 • Suit : Prix • Précède : Tests PHPUnit
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Spec liée** : [`spec-back.md § 4.6`](./spec-back.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] `GET /api/carriers/export.xlsx` : transporteurs affichés (mêmes filtres) ; colonnes § 4.6.
|
||||
- [ ] `GET /api/carriers/{id}/prices/export.xlsx` : tableau Prix regroupé Benne / Fond Mouvant (colonnes docx p.10).
|
||||
- [ ] Controllers custom `#[Route(priority: 1)]` (conflit API Platform `{id}`) ; `Content-Disposition`.
|
||||
**Tests à prévoir** : PHPUnit — 200 + en-tête fichier ; respect des filtres.
|
||||
**Tips** : PhpSpreadsheet déjà présent.
|
||||
|
||||
### 1.11 — Tests PHPUnit RG-4.01→4.14 + capture contrat JSON (DoD)
|
||||
**Position** : 1.11 • Suit : Export • Précède : Page Répertoire
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Spec liée** : [`spec-back.md § 4.0.bis / 8.1`](./spec-back.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Matrice RG-4.01→4.14 couverte (§ 8.1) + RBAC par rôle (Compta/Usine → 403).
|
||||
- [ ] `CarrierSerializationContractTest` : capture JSON réel **liste + détail** ; `prices[].client`/`.supplier`/sites **embarqués** (pas IRI) ; `qualimatCarrier` embarqué ; `isArchived` présent.
|
||||
- [ ] Anti-N+1 liste ; pagination Hydra ; audit (`entity_type='Carrier'`) ; `AuditableEntitiesHaveI18nLabelTest` vert.
|
||||
- [ ] `CarrierFixtures` idempotent (§ 8.4) : transporteur QUALIMAT (validité passée), AUTRE+décharge, affrété, LIOT, complet (contacts/adresses/prix CLIENT+FOURNISSEUR), 1 archivé.
|
||||
**Tests à prévoir** : suite complète `make test` verte.
|
||||
**Tips** : coller les JSON capturés dans § 4.0.bis (DoD avant front).
|
||||
|
||||
### 1.12 — Page Répertoire `/carriers` (datatable, filtres, export)
|
||||
**Position** : 1.12 • Suit : Tests back • Précède : Page Ajouter
|
||||
**Tag** : Frontend • **Effort** : M
|
||||
**Spec liée** : [`spec-front.md § Datatable / Filtres`](./spec-front.md) · Figma `1132-45377`
|
||||
**Critères d'acceptation** :
|
||||
- [ ] `<MalioDataTable>` + `usePaginatedList<Carrier>({url:'/carriers'})` ; colonnes Nom / Certification / Date de validité / Dernière activité.
|
||||
- [ ] **RG-4.04** : date de validité QUALIMAT < aujourd'hui → **fond rouge**.
|
||||
- [ ] Filtres (`search`, `certificationType`, `includeArchived`) → `setFilters` (page 1) ; **état 100 % local** (règle n°6).
|
||||
- [ ] Boutons « + Ajouter » (si `manage`) / « Filtrer » / « Exporter » (XLSX) ; clic ligne → Consultation.
|
||||
**Tests à prévoir** : Vitest — `usePaginatedList` (Hydra, exclusion archivés).
|
||||
**Tips** : `useApi()` obligatoire ; pas de persistance URL.
|
||||
|
||||
### 1.13 — Page Ajouter `/carriers/new` (layout, onglets, formulaire principal POST)
|
||||
**Position** : 1.13 • Suit : Répertoire • Précède : Saisie assistée QUALIMAT
|
||||
**Tag** : Frontend • **Effort** : M
|
||||
**Spec liée** : [`spec-front.md § Écran Ajouter / Formulaire principal`](./spec-front.md) · Figma node `1132-45382` (Ajouter – Qualimat)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Layout + barre d'onglets `Qualimat · Adresses · Contacts · Prix` ; validation incrémentale (onglet suivant accessible après validation).
|
||||
- [ ] Formulaire principal (Nom, Liste certification, Affréter, …) → `POST /api/carriers` ; succès → bascule onglet + champs readonly.
|
||||
- [ ] `useFormErrors` : mapping 422 inline par champ ; `{ toast:false }`.
|
||||
**Tests à prévoir** : Vitest — `useCarrierForm` (workflow par onglet, POST principal).
|
||||
**Tips** : miroir `useSupplierForm`/`useProviderForm`.
|
||||
|
||||
### 1.14 — Saisie assistée QUALIMAT + champs conditionnels
|
||||
**Position** : 1.14 • Suit : Page Ajouter • Précède : Onglet Adresses
|
||||
**Tag** : Frontend • **Effort** : M
|
||||
**Spec liée** : [`spec-front.md § Formulaire principal / Onglet Qualimat`](./spec-front.md) · RG-4.01→4.03 · Figma nodes `1132-50717` (Affréter), `1132-50982` (AUTRE→Décharge), `1132-45593` (LIOT)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] **RG-4.01** : saisie du nom → `GET /api/qualimat_carriers?search=` → modal « Êtes-vous sûr… » → copie Nom + certification (`QUALIMAT`, readonly) + adresse + FK conservée.
|
||||
- [ ] **Cas LIOT** : nom `LIOT` → champ immatriculations seul, autres masqués.
|
||||
- [ ] **RG-4.02** : certification `AUTRE` → champ Décharge visible **et obligatoire** (upload).
|
||||
- [ ] **RG-4.03** : « Affréter » coché → indexation / benne-fond mouvant / volume visibles et obligatoires.
|
||||
**Tests à prévoir** : Vitest — affichage conditionnel (Affréter, AUTRE, LIOT) ; copie QUALIMAT.
|
||||
**Tips** : `useQualimatSearch()` ; `useUpload()` (ticket 1.19) pour la décharge.
|
||||
|
||||
### 1.15 — Onglet Adresses (autocomplete BAN)
|
||||
**Position** : 1.15 • Suit : Saisie QUALIMAT • Précède : Onglet Contacts
|
||||
**Tag** : Frontend • **Effort** : M
|
||||
**Spec liée** : [`spec-front.md § Onglet Adresses`](./spec-front.md) · RG-4.05→4.07 · Figma node `1132-45670`
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Bloc adresse (Pays/CP/Ville/Adresse/complément) → `PATCH /api/carriers/{id}/addresses`.
|
||||
- [ ] **RG-4.06** : `useAddressAutocomplete()` (BAN) — ville auto depuis CP, dégradé texte libre.
|
||||
- [ ] **RG-4.05** : champs préremplis si QUALIMAT ; obligatoires si affrété. **RG-4.07** : pas de bouton Valider si QUALIMAT.
|
||||
**Tests à prévoir** : Vitest — autocomplete nominal + dégradé (réutilisation M1/M2/M3).
|
||||
**Tips** : ne pas réécrire `useAddressAutocomplete()`.
|
||||
|
||||
### 1.16 — Onglet Contacts
|
||||
**Position** : 1.16 • Suit : Adresses • Précède : Onglet Prix
|
||||
**Tag** : Frontend • **Effort** : S
|
||||
**Spec liée** : [`spec-front.md § Onglet Contacts`](./spec-front.md) · RG-4.08 · Figma node `1132-45756`
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Blocs contact (Nom/Prénom/Fonction/Téléphone x1-2/Email) → `PATCH /api/carriers/{id}/contacts`.
|
||||
- [ ] **RG-4.08** : « + Nouveau contact » bloqué tant que le bloc courant est vide ; suppression avec modal.
|
||||
**Tests à prévoir** : Vitest — règle « ≥ 1 champ », max 2 téléphones.
|
||||
**Tips** : `mapViolationsToRecord` par ligne (pattern collections M1/M2/M3).
|
||||
|
||||
### 1.17 — Onglet Prix (Client/Fournisseur, sites)
|
||||
**Position** : 1.17 • Suit : Contacts • Précède : Consultation/Modification
|
||||
**Tag** : Frontend • **Effort** : M
|
||||
**Spec liée** : [`spec-front.md § Onglet Prix`](./spec-front.md) · RG-4.09→4.11 · Figma node `1132-45859`
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Radio `direction` (Client/Fournisseur) → bascule des champs (**RG-4.09**).
|
||||
- [ ] **RG-4.10** (Client) : Client + Adresse de livraison (du client) + Adresse de départ (86/17/82).
|
||||
- [ ] **RG-4.11** (Fournisseur) : Fournisseur + Adresse d'approvisionnement + Adresse de livraison (86/17/82).
|
||||
- [ ] Communs : Benne/FM, Forfait/Tonne, Prix (`MalioInputAmount`), État du prix → `PATCH /api/carriers/{id}/prices`.
|
||||
**Tests à prévoir** : Vitest — bascule Client/Fournisseur, champs requis.
|
||||
**Tips** : selects clients/fournisseurs/sites via endpoints existants (security élargie § 4.8).
|
||||
|
||||
### 1.18 — Pages Consultation + Modification
|
||||
**Position** : 1.18 • Suit : Onglet Prix • Précède : Upload/i18n
|
||||
**Tag** : Frontend • **Effort** : M
|
||||
**Spec liée** : [`spec-front.md § Consultation / Modification`](./spec-front.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Consultation readonly (ouvre sur Adresses) ; flèche retour ; « Modifier » (si `manage`) ; « Archiver » (Admin) → PATCH `isArchived`.
|
||||
- [ ] Onglet Prix consultation = tableau regroupé Benne/FM + bouton Exporter (XLSX).
|
||||
- [ ] Modification = mêmes formulaires, champs pré-remplis, PATCH partiel par onglet.
|
||||
**Tests à prévoir** : Vitest — `useCarrier(id)` peuple les écrans depuis une seule réponse ; visibilité boutons par permission.
|
||||
**Tips** : « Restaurer » remplace « Archiver » sur un archivé.
|
||||
|
||||
### 1.19 — Upload front (`useUpload`) + i18n + libellés audit
|
||||
**Position** : 1.19 • Suit : Consultation/Modification • Précède : —
|
||||
**Tag** : Frontend • **Effort** : S
|
||||
**Spec liée** : [`spec-back.md § 2.7 / 2.8`](./spec-back.md) · [`spec-front.md § Composables`](./spec-front.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Composable `useUpload()` : `POST /api/uploaded_documents` (multipart) → IRI posée sur `carrier.dischargeDocument` (RG-4.02).
|
||||
- [ ] Clés i18n : libellés certification, sidebar (`sidebar.transport.*`), **libellés audit** `audit.entity.transport_carrier/carrieraddress/carriercontact/carrierprice`.
|
||||
- [ ] `<MalioInputUpload>` (exception documentée si type non couvert).
|
||||
**Tests à prévoir** : Vitest — `useUpload` (succès + erreur MIME).
|
||||
**Tips** : `AuditableEntitiesHaveI18nLabelTest` exige les clés audit.
|
||||
|
||||
---
|
||||
|
||||
## Actions Lesstime (à exécuter au feu vert de Matthieu)
|
||||
|
||||
1. `create-group` projectId 6, title « M4 — Répertoire transporteurs » → récupérer l'`id`.
|
||||
2. `create-task` ×19 (statut `Prêt à dev` = 6, priorité Moyen=2, effort dans la description), dans l'ordre 1.1 → 1.19 :
|
||||
- Tickets **1.1 → 1.11** (Backend, tag `3`) → **assigné à Matthieu**.
|
||||
- Tickets **1.12 → 1.19** (Frontend, tag `2`) → **assigné à Tristan**.
|
||||
3. Mettre à jour le frontmatter des specs (`lesstime_taskgroup_id`) + lien du groupe.
|
||||
|
||||
> Au push : récupérer les `userId` via `list-users` (Matthieu = `5` selon le référentiel ; Tristan à confirmer) pour renseigner l'assignation à la création.
|
||||
@@ -2,6 +2,7 @@
|
||||
"common": {
|
||||
"loading": "Chargement...",
|
||||
"save": "Enregistrer",
|
||||
"validate": "Valider",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"edit": "Modifier",
|
||||
@@ -34,6 +35,10 @@
|
||||
"section": "Technique",
|
||||
"providers": "Répertoire prestataires"
|
||||
},
|
||||
"transport": {
|
||||
"section": "Transport",
|
||||
"carriers": "Répertoire transporteurs"
|
||||
},
|
||||
"core": {
|
||||
"roles": "Gestion des rôles",
|
||||
"users": "Utilisateurs",
|
||||
@@ -70,7 +75,7 @@
|
||||
"categories": "Catégories",
|
||||
"sites": "Sites",
|
||||
"status": "Statut",
|
||||
"includeArchived": "Inclure les archivés",
|
||||
"archivedOnly": "Voir les archivés",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
@@ -119,7 +124,7 @@
|
||||
"back": "Retour au répertoire",
|
||||
"loading": "Chargement du fournisseur…",
|
||||
"notFound": "Fournisseur introuvable.",
|
||||
"save": "Valider"
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"form": {
|
||||
"title": "Ajouter un fournisseur",
|
||||
@@ -262,7 +267,7 @@
|
||||
"back": "Retour au répertoire",
|
||||
"loading": "Chargement du client…",
|
||||
"notFound": "Client introuvable.",
|
||||
"save": "Valider"
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"validation": {
|
||||
"informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.",
|
||||
@@ -384,7 +389,7 @@
|
||||
"categories": "Catégories",
|
||||
"sites": "Sites",
|
||||
"status": "Statut",
|
||||
"includeArchived": "Inclure les archivés",
|
||||
"archivedOnly": "Voir les archivés",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
@@ -490,6 +495,62 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport": {
|
||||
"carriers": {
|
||||
"title": "Répertoire transporteurs",
|
||||
"add": "Ajouter",
|
||||
"export": "Exporter",
|
||||
"empty": "Aucun transporteur pour l'instant.",
|
||||
"column": {
|
||||
"name": "Nom",
|
||||
"certification": "Certification",
|
||||
"validityDate": "Date de validité",
|
||||
"lastActivity": "Dernière activité"
|
||||
},
|
||||
"certification": {
|
||||
"QUALIMAT": "QUALIMAT",
|
||||
"GMP_PLUS": "GMP+",
|
||||
"OVOCOM": "OVOCOM",
|
||||
"COMPTE_PROPRE": "Compte-propre",
|
||||
"AUTRE": "Autre"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"search": "Recherche",
|
||||
"certification": "Certification",
|
||||
"status": "Statut",
|
||||
"archivedOnly": "Voir les archivés",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
"toast": {
|
||||
"error": "Une erreur est survenue. Réessayez.",
|
||||
"exportError": "L'export du répertoire transporteurs a échoué. Réessayez.",
|
||||
"createSuccess": "Transporteur créé avec succès"
|
||||
},
|
||||
"tab": {
|
||||
"qualimat": "Qualimat",
|
||||
"addresses": "Adresses",
|
||||
"contacts": "Contacts",
|
||||
"prices": "Prix"
|
||||
},
|
||||
"form": {
|
||||
"title": "Ajouter un transporteur",
|
||||
"back": "Retour au répertoire",
|
||||
"submit": "Valider",
|
||||
"comingSoon": "À venir",
|
||||
"duplicateName": "Un transporteur actif portant ce nom existe déjà.",
|
||||
"main": {
|
||||
"name": "Nom",
|
||||
"certificationType": "Certification transport",
|
||||
"isChartered": "Affréter"
|
||||
},
|
||||
"errors": {
|
||||
"nameRequired": "Le nom du transporteur est obligatoire."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"login": "Connexion",
|
||||
"logout": "Deconnexion",
|
||||
@@ -548,7 +609,11 @@
|
||||
"technique_provider": "Prestataire",
|
||||
"technique_provideraddress": "Adresse prestataire",
|
||||
"technique_providercontact": "Contact prestataire",
|
||||
"technique_providerrib": "RIB prestataire"
|
||||
"technique_providerrib": "RIB prestataire",
|
||||
"transport_carrier": "Transporteur",
|
||||
"transport_carrieraddress": "Adresse transporteur",
|
||||
"transport_carriercontact": "Contact transporteur",
|
||||
"transport_carrierprice": "Prix transporteur"
|
||||
},
|
||||
"empty": "Aucune activité enregistrée",
|
||||
"no_results": "Aucun résultat pour ces filtres",
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canShowSave"
|
||||
:label="t('common.save')"
|
||||
:label="isCreateMode ? t('common.validate') : t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-m-btn-action"
|
||||
:disabled="form.submitting.value || loadingTypes"
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('useSuppliersRepository', () => {
|
||||
search: 'acme',
|
||||
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
|
||||
'siteId[]': ['86', '17'],
|
||||
includeArchived: true,
|
||||
archivedOnly: true,
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
@@ -63,7 +63,7 @@ describe('useSuppliersRepository', () => {
|
||||
search: 'acme',
|
||||
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
|
||||
'siteId[]': ['86', '17'],
|
||||
includeArchived: true,
|
||||
archivedOnly: true,
|
||||
page: 1,
|
||||
itemsPerPage: 10,
|
||||
},
|
||||
@@ -73,7 +73,7 @@ describe('useSuppliersRepository', () => {
|
||||
|
||||
it('repasse a une query propre apres reinitialisation des filtres', async () => {
|
||||
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 })
|
||||
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
|
||||
@@ -41,9 +41,10 @@ export interface Supplier {
|
||||
* sur la ressource `/suppliers` (RG-13 : pagination serveur obligatoire ; jamais
|
||||
* de chargement integral en memoire). Miroir de `useClientsRepository` (M1).
|
||||
*
|
||||
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
|
||||
* par la page via `setFilters` du composable partage — la remise en page 1 est
|
||||
* garantie.
|
||||
* Les filtres (recherche, categories, sites, archives) sont pilotes par la page
|
||||
* via `setFilters` du composable partage — la remise en page 1 est garantie.
|
||||
* Cocher « Voir les archivés » envoie `archivedOnly=true` → seules les archives
|
||||
* sont listees (aligne sur Client).
|
||||
*
|
||||
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
|
||||
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
|
||||
|
||||
@@ -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()
|
||||
await flushPromises()
|
||||
|
||||
// Coche « Inclure les archivés » puis applique les filtres.
|
||||
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true)
|
||||
// Coche « Voir les archivés » puis applique les filtres.
|
||||
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
|
||||
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
|
||||
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||
{ includeArchived: true },
|
||||
{ archivedOnly: true },
|
||||
{ replace: true },
|
||||
)
|
||||
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
|
||||
@@ -192,7 +192,7 @@ describe('Répertoire fournisseurs (page /suppliers)', () => {
|
||||
const wrapper = mountPage()
|
||||
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')
|
||||
|
||||
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
|
||||
|
||||
@@ -128,13 +128,13 @@
|
||||
</div>
|
||||
</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">
|
||||
<MalioCheckbox
|
||||
id="filter-include-archived"
|
||||
:label="t('commercial.suppliers.filters.includeArchived')"
|
||||
:model-value="draftIncludeArchived"
|
||||
@update:model-value="(val: boolean) => draftIncludeArchived = val"
|
||||
id="filter-archived-only"
|
||||
:label="t('commercial.suppliers.filters.archivedOnly')"
|
||||
:model-value="draftArchivedOnly"
|
||||
@update:model-value="(val: boolean) => draftArchivedOnly = val"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
@@ -254,12 +254,12 @@ const filterDrawerOpen = ref(false)
|
||||
const draftSearch = ref('')
|
||||
const draftCategoryCodes = ref<string[]>([])
|
||||
const draftSiteIds = ref<string[]>([])
|
||||
const draftIncludeArchived = ref(false)
|
||||
const draftArchivedOnly = ref(false)
|
||||
|
||||
const appliedSearch = ref('')
|
||||
const appliedCategoryCodes = ref<string[]>([])
|
||||
const appliedSiteIds = ref<string[]>([])
|
||||
const appliedIncludeArchived = ref(false)
|
||||
const appliedArchivedOnly = ref(false)
|
||||
|
||||
// Options des selects multi, chargees une fois (referentiels courts).
|
||||
const categoryOptions = ref<FilterOption[]>([])
|
||||
@@ -270,7 +270,7 @@ const activeFilterCount = computed(() => {
|
||||
if (appliedSearch.value.trim() !== '') count++
|
||||
if (appliedCategoryCodes.value.length > 0) count++
|
||||
if (appliedSiteIds.value.length > 0) count++
|
||||
if (appliedIncludeArchived.value) count++
|
||||
if (appliedArchivedOnly.value) count++
|
||||
return count
|
||||
})
|
||||
|
||||
@@ -285,7 +285,7 @@ function openFilters(): void {
|
||||
draftSearch.value = appliedSearch.value
|
||||
draftCategoryCodes.value = [...appliedCategoryCodes.value]
|
||||
draftSiteIds.value = [...appliedSiteIds.value]
|
||||
draftIncludeArchived.value = appliedIncludeArchived.value
|
||||
draftArchivedOnly.value = appliedArchivedOnly.value
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ function buildFilterPayload(): Record<string, string | string[] | boolean> {
|
||||
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
||||
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
|
||||
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
|
||||
if (appliedIncludeArchived.value) payload.includeArchived = true
|
||||
if (appliedArchivedOnly.value) payload.archivedOnly = true
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ function applyFilters(): void {
|
||||
appliedSearch.value = draftSearch.value.trim()
|
||||
appliedCategoryCodes.value = [...draftCategoryCodes.value]
|
||||
appliedSiteIds.value = [...draftSiteIds.value]
|
||||
appliedIncludeArchived.value = draftIncludeArchived.value
|
||||
appliedArchivedOnly.value = draftArchivedOnly.value
|
||||
|
||||
setFilters(buildFilterPayload(), { replace: true })
|
||||
filterDrawerOpen.value = false
|
||||
@@ -333,12 +333,12 @@ function resetFilters(): void {
|
||||
draftSearch.value = ''
|
||||
draftCategoryCodes.value = []
|
||||
draftSiteIds.value = []
|
||||
draftIncludeArchived.value = false
|
||||
draftArchivedOnly.value = false
|
||||
|
||||
appliedSearch.value = ''
|
||||
appliedCategoryCodes.value = []
|
||||
appliedSiteIds.value = []
|
||||
appliedIncludeArchived.value = false
|
||||
appliedArchivedOnly.value = false
|
||||
|
||||
setFilters({}, { replace: true })
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
:label="isEditMode ? t('common.save') : t('common.validate')"
|
||||
variant="primary"
|
||||
button-class="w-m-btn-action"
|
||||
:disabled="saving || permissionsLoadFailed"
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
:label="isEditMode ? t('common.save') : t('common.validate')"
|
||||
variant="primary"
|
||||
button-class="w-m-btn-action"
|
||||
:disabled="saving || !isValidHex"
|
||||
|
||||
@@ -14,9 +14,9 @@ vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
* - l'enveloppe Hydra (member / totalItems) est consommee
|
||||
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
|
||||
* renvoie un tableau plat sans pagination)
|
||||
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `includeArchived` n'est envoye
|
||||
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `archivedOnly` n'est envoye
|
||||
* tant que l'utilisateur ne coche pas le filtre (le back masque alors les
|
||||
* archives) ; le filtre `includeArchived` est bien transmis une fois applique.
|
||||
* archives) ; le filtre `archivedOnly` est bien transmis une fois applique.
|
||||
*/
|
||||
describe('useProvidersRepository', () => {
|
||||
beforeEach(() => {
|
||||
@@ -53,26 +53,26 @@ describe('useProvidersRepository', () => {
|
||||
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 })
|
||||
const repo = useProvidersRepository()
|
||||
|
||||
await repo.fetch()
|
||||
|
||||
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 })
|
||||
const repo = useProvidersRepository()
|
||||
await repo.fetch()
|
||||
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
await repo.setFilters({ includeArchived: true })
|
||||
await repo.setFilters({ archivedOnly: true })
|
||||
|
||||
expect(repo.currentPage.value).toBe(1)
|
||||
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(query.includeArchived).toBe(true)
|
||||
expect(query.archivedOnly).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,10 +45,11 @@ export interface Provider {
|
||||
* sur la ressource `/providers` (pagination serveur obligatoire ; jamais de
|
||||
* chargement integral en memoire). Miroir de `useSuppliersRepository` (M2).
|
||||
*
|
||||
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
|
||||
* par la page via `setFilters` du composable partage — la remise en page 1 est
|
||||
* garantie. Par defaut, aucun `includeArchived` n'est envoye : le back masque
|
||||
* donc les prestataires archives (exclusion par defaut, spec-back § 2.11).
|
||||
* Les filtres (recherche, categories, sites, archives) sont pilotes par la page
|
||||
* via `setFilters` du composable partage — la remise en page 1 est garantie. Par
|
||||
* defaut, aucun `archivedOnly` n'est envoye : le back masque donc les prestataires
|
||||
* 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
|
||||
* fonction de l'utilisateur — rien a filtrer cote front.
|
||||
|
||||
@@ -129,13 +129,13 @@
|
||||
</div>
|
||||
</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">
|
||||
<MalioCheckbox
|
||||
id="filter-include-archived"
|
||||
:label="t('technique.providers.filters.includeArchived')"
|
||||
:model-value="draftIncludeArchived"
|
||||
@update:model-value="(val: boolean) => draftIncludeArchived = val"
|
||||
id="filter-archived-only"
|
||||
:label="t('technique.providers.filters.archivedOnly')"
|
||||
:model-value="draftArchivedOnly"
|
||||
@update:model-value="(val: boolean) => draftArchivedOnly = val"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
@@ -258,12 +258,12 @@ const filterDrawerOpen = ref(false)
|
||||
const draftSearch = ref('')
|
||||
const draftCategoryCodes = ref<string[]>([])
|
||||
const draftSiteIds = ref<string[]>([])
|
||||
const draftIncludeArchived = ref(false)
|
||||
const draftArchivedOnly = ref(false)
|
||||
|
||||
const appliedSearch = ref('')
|
||||
const appliedCategoryCodes = ref<string[]>([])
|
||||
const appliedSiteIds = ref<string[]>([])
|
||||
const appliedIncludeArchived = ref(false)
|
||||
const appliedArchivedOnly = ref(false)
|
||||
|
||||
// Options des selects multi, chargees une fois (referentiels courts).
|
||||
const categoryOptions = ref<FilterOption[]>([])
|
||||
@@ -274,7 +274,7 @@ const activeFilterCount = computed(() => {
|
||||
if (appliedSearch.value.trim() !== '') count++
|
||||
if (appliedCategoryCodes.value.length > 0) count++
|
||||
if (appliedSiteIds.value.length > 0) count++
|
||||
if (appliedIncludeArchived.value) count++
|
||||
if (appliedArchivedOnly.value) count++
|
||||
return count
|
||||
})
|
||||
|
||||
@@ -289,7 +289,7 @@ function openFilters(): void {
|
||||
draftSearch.value = appliedSearch.value
|
||||
draftCategoryCodes.value = [...appliedCategoryCodes.value]
|
||||
draftSiteIds.value = [...appliedSiteIds.value]
|
||||
draftIncludeArchived.value = appliedIncludeArchived.value
|
||||
draftArchivedOnly.value = appliedArchivedOnly.value
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ function buildFilterPayload(): Record<string, string | string[] | boolean> {
|
||||
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
||||
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
|
||||
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
|
||||
if (appliedIncludeArchived.value) payload.includeArchived = true
|
||||
if (appliedArchivedOnly.value) payload.archivedOnly = true
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -325,7 +325,7 @@ function applyFilters(): void {
|
||||
appliedSearch.value = draftSearch.value.trim()
|
||||
appliedCategoryCodes.value = [...draftCategoryCodes.value]
|
||||
appliedSiteIds.value = [...draftSiteIds.value]
|
||||
appliedIncludeArchived.value = draftIncludeArchived.value
|
||||
appliedArchivedOnly.value = draftArchivedOnly.value
|
||||
|
||||
setFilters(buildFilterPayload(), { replace: true })
|
||||
filterDrawerOpen.value = false
|
||||
@@ -337,12 +337,12 @@ function resetFilters(): void {
|
||||
draftSearch.value = ''
|
||||
draftCategoryCodes.value = []
|
||||
draftSiteIds.value = []
|
||||
draftIncludeArchived.value = false
|
||||
draftArchivedOnly.value = false
|
||||
|
||||
appliedSearch.value = ''
|
||||
appliedCategoryCodes.value = []
|
||||
appliedSiteIds.value = []
|
||||
appliedIncludeArchived.value = false
|
||||
appliedArchivedOnly.value = false
|
||||
|
||||
setFilters({}, { replace: true })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Tests du workflow « Ajouter un transporteur » (M4 Transport, ERP-165).
|
||||
*
|
||||
* `useCarrierForm` porte le formulaire principal (Nom + Certification + Affréter)
|
||||
* et l'orchestration des onglets de création. On vérifie ici le CONTRAT propre à
|
||||
* la création :
|
||||
* - pré-check front : nom requis → POST bloqué, erreur inline, aucun appel réseau ;
|
||||
* - POST /carriers (groupe carrier:write:main) : payload + Accept ld+json +
|
||||
* toast:false ; au succès, verrouillage + bascule sur l'onglet Qualimat +
|
||||
* réaffichage du nom normalisé ;
|
||||
* - 409 doublon (RG-4.12) → erreur inline dédiée sur `name` ;
|
||||
* - 422 → mapping inline par champ (propertyPath) ;
|
||||
* - onglets : 4 onglets (Qualimat/Adresses/Contacts/Prix), completeTab
|
||||
* déverrouille/avance et signale le dernier onglet ;
|
||||
* - patchCarrier : PATCH partiel, no-op avant création.
|
||||
*/
|
||||
|
||||
const mockPost = vi.hoisted(() => vi.fn())
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useApi', () => ({
|
||||
get: vi.fn(),
|
||||
post: mockPost,
|
||||
put: vi.fn(),
|
||||
patch: mockPatch,
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('useToast', () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
}))
|
||||
|
||||
const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm')
|
||||
|
||||
describe('useCarrierForm', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
})
|
||||
|
||||
it('front : nom vide → erreur inline sur name, pas de POST', async () => {
|
||||
const form = useCarrierForm()
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.mainErrors.errors.name).toBe('transport.carriers.form.errors.nameRequired')
|
||||
expect(form.mainLocked.value).toBe(false)
|
||||
})
|
||||
|
||||
it('front : nom en espaces uniquement → erreur inline, pas de POST', async () => {
|
||||
const form = useCarrierForm()
|
||||
form.main.name = ' '
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.mainErrors.errors.name).toBe('transport.carriers.form.errors.nameRequired')
|
||||
})
|
||||
|
||||
it('POST /carriers avec Accept ld+json, verrouille et bascule sur Qualimat', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 42, name: 'TRANSPORTS ACME', certificationType: 'GMP_PLUS' })
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Transports Acme'
|
||||
form.main.certificationType = 'GMP_PLUS'
|
||||
form.main.isChartered = true
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(true)
|
||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||
expect(url).toBe('/carriers')
|
||||
expect(body).toEqual({
|
||||
name: 'Transports Acme',
|
||||
certificationType: 'GMP_PLUS',
|
||||
isChartered: true,
|
||||
})
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
|
||||
expect(form.carrierId.value).toBe(42)
|
||||
// RG-4.13 : réaffiche le nom normalisé (UPPERCASE) renvoyé par le serveur.
|
||||
expect(form.main.name).toBe('TRANSPORTS ACME')
|
||||
expect(form.mainLocked.value).toBe(true)
|
||||
expect(form.activeTab.value).toBe('qualimat')
|
||||
expect(form.unlockedIndex.value).toBe(0)
|
||||
})
|
||||
|
||||
it('payload : omet name et certificationType vides, garde isChartered', async () => {
|
||||
mockPost.mockRejectedValueOnce({ response: { status: 422, _data: { violations: [] } } })
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'X' // nom présent pour passer le pré-check front
|
||||
// certificationType laissé null → omis pour que la 422 « obligatoire » porte.
|
||||
|
||||
await form.submitMain()
|
||||
|
||||
const body = mockPost.mock.calls[0]?.[1] as Record<string, unknown>
|
||||
expect(body).toEqual({ name: 'X', isChartered: false })
|
||||
expect('certificationType' in body).toBe(false)
|
||||
})
|
||||
|
||||
it('409 doublon (RG-4.12) : erreur inline dédiée sur name, pas de verrouillage', async () => {
|
||||
mockPost.mockRejectedValueOnce({ response: { status: 409 } })
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Doublon'
|
||||
form.main.certificationType = 'AUTRE'
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(form.mainErrors.errors.name).toBe('transport.carriers.form.duplicateName')
|
||||
expect(form.mainLocked.value).toBe(false)
|
||||
})
|
||||
|
||||
it('422 : mappe les violations serveur inline par champ', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'certificationType', message: 'Le type de certification est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Sans Certif'
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(form.mainErrors.errors.certificationType).toBe('Le type de certification est obligatoire.')
|
||||
expect(form.mainLocked.value).toBe(false)
|
||||
})
|
||||
|
||||
it('onglets : 4 clés Qualimat/Adresses/Contacts/Prix', () => {
|
||||
expect(CARRIER_TAB_KEYS).toEqual(['qualimat', 'addresses', 'contacts', 'prices'])
|
||||
const form = useCarrierForm()
|
||||
expect(form.tabKeys.value).toEqual(['qualimat', 'addresses', 'contacts', 'prices'])
|
||||
// Tous verrouillés tant que le formulaire principal n'est pas validé.
|
||||
expect(form.unlockedIndex.value).toBe(-1)
|
||||
})
|
||||
|
||||
it('completeTab : déverrouille/avance, et signale le dernier onglet du flux', () => {
|
||||
const form = useCarrierForm()
|
||||
|
||||
// Qualimat → Adresses (pas le dernier).
|
||||
expect(form.completeTab('qualimat')).toBe(false)
|
||||
expect(form.isValidated('qualimat')).toBe(true)
|
||||
expect(form.activeTab.value).toBe('addresses')
|
||||
expect(form.unlockedIndex.value).toBe(1)
|
||||
|
||||
expect(form.completeTab('addresses')).toBe(false)
|
||||
expect(form.activeTab.value).toBe('contacts')
|
||||
|
||||
expect(form.completeTab('contacts')).toBe(false)
|
||||
expect(form.activeTab.value).toBe('prices')
|
||||
|
||||
// Prix = dernier onglet → true (création terminée).
|
||||
expect(form.completeTab('prices')).toBe(true)
|
||||
expect(form.isValidated('prices')).toBe(true)
|
||||
})
|
||||
|
||||
it('editMode : completeTab ne verrouille pas et ne bascule pas d\'onglet', () => {
|
||||
const form = useCarrierForm()
|
||||
form.editMode.value = true
|
||||
form.activeTab.value = 'qualimat'
|
||||
|
||||
expect(form.completeTab('qualimat')).toBe(false)
|
||||
expect(form.isValidated('qualimat')).toBe(false)
|
||||
expect(form.activeTab.value).toBe('qualimat')
|
||||
})
|
||||
|
||||
it('patchCarrier : PATCH /carriers/{id} en mode strict, no-op avant création', async () => {
|
||||
const form = useCarrierForm()
|
||||
|
||||
await form.patchCarrier({ liotPlates: 'AA-123-BB' })
|
||||
expect(mockPatch).not.toHaveBeenCalled()
|
||||
|
||||
mockPost.mockResolvedValueOnce({ id: 9, name: 'ACME', certificationType: 'OVOCOM' })
|
||||
form.main.name = 'Acme'
|
||||
form.main.certificationType = 'OVOCOM'
|
||||
await form.submitMain()
|
||||
|
||||
await form.patchCarrier({ liotPlates: 'AA-123-BB' })
|
||||
expect(mockPatch).toHaveBeenCalledWith('/carriers/9', { liotPlates: 'AA-123-BB' }, { toast: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useCarriersRepository, type Carrier } from '../useCarriersRepository'
|
||||
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
/**
|
||||
* Tests du repertoire transporteurs (ERP-164).
|
||||
*
|
||||
* `useCarriersRepository` est une fine enveloppe de `usePaginatedList<Carrier>`
|
||||
* sur `/carriers`. Les invariants generiques de pagination sont deja couverts par
|
||||
* `usePaginatedList.test.ts` ; on verifie ici le CONTRAT propre au repertoire :
|
||||
* - la ressource ciblee est bien `/carriers` ;
|
||||
* - l'enveloppe Hydra (member / totalItems) est consommee ;
|
||||
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
|
||||
* renvoie un tableau plat sans pagination) ;
|
||||
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `archivedOnly` n'est envoye
|
||||
* tant que l'utilisateur ne coche pas le filtre (le back masque alors les
|
||||
* archives) ; le filtre « Voir les archivés » est bien transmis une fois
|
||||
* applique (aligne sur Client / Fournisseur / Prestataire).
|
||||
*/
|
||||
describe('useCarriersRepository', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
})
|
||||
|
||||
/** Une page de transporteurs Hydra, avec qualimatCarrier embarque (RG-4.04). */
|
||||
const PAGE: Carrier[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TRANSPORTS ACME',
|
||||
certificationType: 'QUALIMAT',
|
||||
qualimatCarrier: {
|
||||
id: '42',
|
||||
name: 'TRANSPORTS ACME',
|
||||
validityDate: '2027-01-15',
|
||||
status: 'VALIDE',
|
||||
},
|
||||
updatedAt: '2026-06-15T08:12:01+02:00',
|
||||
isArchived: false,
|
||||
},
|
||||
]
|
||||
|
||||
it('cible /carriers, consomme l\'enveloppe Hydra et envoie l\'Accept ld+json', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useCarriersRepository()
|
||||
|
||||
await repo.fetch()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||
expect(url).toBe('/carriers')
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
||||
expect(opts).toMatchObject({
|
||||
toast: false,
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
})
|
||||
expect(repo.items.value).toEqual(PAGE)
|
||||
expect(repo.totalItems.value).toBe(1)
|
||||
})
|
||||
|
||||
it('exclut les archives par defaut : aucun archivedOnly au premier fetch', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useCarriersRepository()
|
||||
|
||||
await repo.fetch()
|
||||
|
||||
const query = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
||||
expect(query.archivedOnly).toBeUndefined()
|
||||
})
|
||||
|
||||
it('transmet archivedOnly une fois le filtre applique (retour page 1)', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useCarriersRepository()
|
||||
await repo.fetch()
|
||||
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
await repo.setFilters({ archivedOnly: true })
|
||||
|
||||
expect(repo.currentPage.value).toBe(1)
|
||||
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(query.archivedOnly).toBe(true)
|
||||
})
|
||||
|
||||
it('transmet les certifications multiples + la recherche', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useCarriersRepository()
|
||||
await repo.fetch()
|
||||
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
await repo.setFilters({ search: 'acme', 'certificationType[]': ['QUALIMAT', 'AUTRE'] })
|
||||
|
||||
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(query.search).toBe('acme')
|
||||
expect(query['certificationType[]']).toEqual(['QUALIMAT', 'AUTRE'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||
import {
|
||||
emptyCarrierMain,
|
||||
type CarrierMainDraft,
|
||||
type CarrierMainResponse,
|
||||
} from '~/modules/transport/types/carrierForm'
|
||||
|
||||
/**
|
||||
* Workflow de l'écran « Ajouter un transporteur » (M4 Transport, ERP-165) —
|
||||
* miroir conceptuel de `useSupplierForm` (M2) / `useProviderForm` (M3).
|
||||
*
|
||||
* Périmètre ERP-165 : le formulaire PRINCIPAL (pré-onglets) et l'orchestration de
|
||||
* la barre d'onglets. La création est INCRÉMENTALE (spec-front § Écran Ajouter) :
|
||||
* - on POST d'abord le formulaire principal (`POST /api/carriers`) ;
|
||||
* - au succès le bloc principal passe en lecture seule, le 1er onglet (Qualimat)
|
||||
* se déverrouille et devient actif ;
|
||||
* - chaque onglet validé déverrouille le suivant (PATCH partiels par groupe de
|
||||
* sérialisation) et passe en lecture seule.
|
||||
*
|
||||
* Les champs conditionnels du formulaire principal (indexation / benne / volume
|
||||
* si affrété, décharge si AUTRE, cas LIOT) et la saisie assistée QUALIMAT arrivent
|
||||
* à ERP-166 ; le contenu des onglets Adresses / Contacts / Prix aux tickets
|
||||
* suivants. Ce composable pose le POST principal, le PATCH partiel et le gating
|
||||
* des onglets.
|
||||
*
|
||||
* État 100 % local à l'instance (refs / reactive) — aucune persistance URL.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Clés des onglets du flux de création, dans l'ordre de la barre (spec-front
|
||||
* § Écran Ajouter). Toujours les 4 (pas de gating par permission au M4, ≠ l'onglet
|
||||
* Comptabilité du M3).
|
||||
*/
|
||||
export const CARRIER_TAB_KEYS = ['qualimat', 'addresses', 'contacts', 'prices'] as const
|
||||
|
||||
export function useCarrierForm() {
|
||||
const api = useApi()
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
||||
const mainErrors = useFormErrors()
|
||||
|
||||
// ── État du transporteur créé ─────────────────────────────────────────────
|
||||
const carrierId = ref<number | null>(null)
|
||||
const mainLocked = ref(false)
|
||||
const mainSubmitting = ref(false)
|
||||
|
||||
// ── Formulaire principal ──────────────────────────────────────────────────
|
||||
const main = reactive<CarrierMainDraft>(emptyCarrierMain())
|
||||
|
||||
// ── Onglets : ordre + gating progressif ───────────────────────────────────
|
||||
const tabKeys = ref<string[]>([...CARRIER_TAB_KEYS])
|
||||
// Index du dernier onglet déverrouillé (-1 tant que le transporteur n'est pas créé).
|
||||
const unlockedIndex = ref(-1)
|
||||
const activeTab = ref<string>(CARRIER_TAB_KEYS[0])
|
||||
// Onglets validés (passent en lecture seule).
|
||||
const validated = reactive<Record<string, boolean>>({})
|
||||
// Mode MODIFICATION (ticket ultérieur) : navigation libre, pas de verrouillage
|
||||
// ni de bascule automatique d'onglet à la validation (cf. completeTab).
|
||||
const editMode = ref(false)
|
||||
|
||||
function isValidated(key: string): boolean {
|
||||
return validated[key] === true
|
||||
}
|
||||
|
||||
function tabIndex(key: string): number {
|
||||
return tabKeys.value.indexOf(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation FRONT du formulaire principal : seul le nom est requis côté front
|
||||
* (RG-4.01). Le back reste la couche autoritaire (ERP-101) — la certification
|
||||
* obligatoire (sauf LIOT) et les RG conditionnelles sont re-validées serveur et
|
||||
* remontées en 422 inline, sans pré-check front (qui devrait connaître le cas
|
||||
* LIOT, hors périmètre ERP-165).
|
||||
*/
|
||||
function validateMainFront(): boolean {
|
||||
let valid = true
|
||||
if (!main.name?.trim()) {
|
||||
mainErrors.setError('name', t('transport.carriers.form.errors.nameRequired'))
|
||||
valid = false
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload du POST principal (groupe `carrier:write:main`). `name` et
|
||||
* `certificationType` sont omis s'ils sont vides afin que la 422 porte la
|
||||
* violation métier (NotBlank sur le nom, « certification obligatoire » sur la
|
||||
* certification) sur le champ plutôt qu'une erreur de type.
|
||||
*/
|
||||
function buildMainPayload(): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
isChartered: main.isChartered,
|
||||
}
|
||||
if (main.name?.trim()) {
|
||||
payload.name = main.name
|
||||
}
|
||||
if (main.certificationType) {
|
||||
payload.certificationType = main.certificationType
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /carriers (groupe `carrier:write:main`). Pré-check front (nom), puis
|
||||
* création. Au succès : verrouille le bloc principal, déverrouille le 1er onglet
|
||||
* et bascule sur « Qualimat ». Retourne true si créé, false sinon.
|
||||
*/
|
||||
async function submitMain(): Promise<boolean> {
|
||||
if (mainSubmitting.value) return false
|
||||
mainErrors.clearErrors()
|
||||
if (!validateMainFront()) return false
|
||||
|
||||
mainSubmitting.value = true
|
||||
try {
|
||||
const created = await api.post<CarrierMainResponse>('/carriers', buildMainPayload(), {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
|
||||
carrierId.value = created.id
|
||||
// Réaffiche les valeurs normalisées renvoyées par le serveur (nom en
|
||||
// UPPERCASE — RG-4.13 ; certification éventuellement forcée).
|
||||
main.name = created.name ?? main.name
|
||||
main.certificationType = created.certificationType ?? main.certificationType
|
||||
|
||||
mainLocked.value = true
|
||||
unlockedIndex.value = 0
|
||||
activeTab.value = tabKeys.value[0] ?? CARRIER_TAB_KEYS[0]
|
||||
toast.success({ title: t('transport.carriers.toast.createSuccess') })
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
// 409 = doublon de nom (RG-4.12) → erreur inline dédiée + toast ;
|
||||
// 422 → mapping inline par champ ; autre → toast de fallback (ERP-101).
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) {
|
||||
const message = t('transport.carriers.form.duplicateName')
|
||||
mainErrors.setError('name', message)
|
||||
toast.error({ title: t('transport.carriers.toast.error'), message })
|
||||
}
|
||||
else {
|
||||
mainErrors.handleApiError(error, { fallbackMessage: t('transport.carriers.toast.error') })
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
mainSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH partiel du transporteur (mode strict : un seul groupe de sérialisation
|
||||
* par appel — spec-back § 2.9). Servira les onglets à champs scalaires des
|
||||
* tickets suivants. No-op tant que le transporteur n'existe pas.
|
||||
*/
|
||||
async function patchCarrier(payload: Record<string, unknown>): Promise<void> {
|
||||
if (carrierId.value === null) return
|
||||
await api.patch(`/carriers/${carrierId.value}`, payload, { toast: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque un onglet validé (passe en lecture seule), déverrouille et avance à
|
||||
* l'onglet suivant. Retourne true si c'était le dernier onglet du flux (création
|
||||
* terminée), false sinon.
|
||||
*/
|
||||
function completeTab(key: string): boolean {
|
||||
// En modification : navigation libre, l'onglet reste éditable après validation.
|
||||
if (editMode.value) {
|
||||
return false
|
||||
}
|
||||
validated[key] = true
|
||||
const index = tabIndex(key)
|
||||
const next = tabKeys.value[index + 1]
|
||||
if (next === undefined) {
|
||||
return true
|
||||
}
|
||||
unlockedIndex.value = Math.max(unlockedIndex.value, index + 1)
|
||||
activeTab.value = next
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
// état
|
||||
main,
|
||||
carrierId,
|
||||
mainLocked,
|
||||
mainSubmitting,
|
||||
mainErrors,
|
||||
// onglets
|
||||
tabKeys,
|
||||
activeTab,
|
||||
unlockedIndex,
|
||||
validated,
|
||||
editMode,
|
||||
isValidated,
|
||||
// actions
|
||||
validateMainFront,
|
||||
buildMainPayload,
|
||||
submitMain,
|
||||
patchCarrier,
|
||||
completeTab,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||
|
||||
/**
|
||||
* Vue MINIMALE du referentiel QUALIMAT embarque (groupe `qualimat:read`) dans la
|
||||
* LISTE des transporteurs. Seuls les champs consommes par le Repertoire sont
|
||||
* types : `validityDate` alimente la colonne « Date de validité » (fond rouge si
|
||||
* perimee — RG-4.04). L'id QUALIMAT est une chaine (colonne BIGINT cote back).
|
||||
*/
|
||||
export interface CarrierQualimat {
|
||||
id: string
|
||||
name: string | null
|
||||
/** Date ISO de validite de l'agrement QUALIMAT (date_immutable) — RG-4.04. */
|
||||
validityDate: string | null
|
||||
status: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue MINIMALE d'un transporteur pour le Repertoire (datatable). Volontairement
|
||||
* partielle : seuls les champs des colonnes + l'id (navigation) sont types ici.
|
||||
* Le detail complet (onglets Adresses / Contacts / Prix) est hors perimetre de
|
||||
* cet ecran (ERP-164, ticket #9).
|
||||
*
|
||||
* `certificationType` : QUALIMAT | GMP_PLUS | OVOCOM | COMPTE_PROPRE | AUTRE, ou
|
||||
* `null` dans le cas LIOT (compte-propre interne sans certification — RG-4.01).
|
||||
* Le libelle affiche est resolu cote front (cle i18n `transport.carriers.certification.*`).
|
||||
*/
|
||||
export interface Carrier {
|
||||
id: number
|
||||
name: string | null
|
||||
certificationType: string | null
|
||||
/** Lien editable vers le referentiel QUALIMAT (null si transporteur non QUALIMAT). */
|
||||
qualimatCarrier: CarrierQualimat | null
|
||||
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
|
||||
updatedAt: string | null
|
||||
isArchived: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtres du Repertoire transporteurs, branches sur les query params de
|
||||
* `GET /api/carriers` (spec-back § 4.1). Pilotes par la page via `setFilters` :
|
||||
* - `search` : recherche fuzzy sur le nom ;
|
||||
* - `certificationType[]` : multi-valeurs (OR cote back) ;
|
||||
* - `archivedOnly` : n'affiche QUE les archives (toggle « Voir les archivés »,
|
||||
* aligne sur les autres repertoires M1/M2/M3).
|
||||
*/
|
||||
export interface CarrierFilters {
|
||||
search?: string
|
||||
'certificationType[]'?: string[]
|
||||
archivedOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Repertoire transporteurs (M4, ERP-164) — simple enveloppe de
|
||||
* `usePaginatedList<Carrier>` sur la ressource `/carriers` (regle ABSOLUE n°13 :
|
||||
* pagination serveur obligatoire ; jamais de chargement integral en memoire).
|
||||
* Miroir de `useSuppliersRepository` (M2) / `useProvidersRepository` (M3).
|
||||
*
|
||||
* Les filtres (recherche, certifications, archives) sont pilotes par la page via
|
||||
* `setFilters` du composable partage — la remise en page 1 est garantie. Par
|
||||
* defaut AUCUN `archivedOnly` n'est envoye : le back masque alors les archives
|
||||
* (§ 2.4). Cocher « Voir les archivés » envoie `archivedOnly=true` (seules les
|
||||
* archives sont listees, aligne sur Client / Fournisseur / Prestataire).
|
||||
*
|
||||
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
|
||||
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
|
||||
* `usePaginatedList`. Aucun reset au logout a gerer.
|
||||
*/
|
||||
export function useCarriersRepository() {
|
||||
return usePaginatedList<Carrier, CarrierFilters>({ url: '/carriers' })
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
|
||||
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||
// La page ne les importe pas (auto-import) : on les expose en globals pour le
|
||||
// runtime de test (happy-dom). Meme philosophie que les specs M1/M2/M3.
|
||||
const mockPush = vi.hoisted(() => vi.fn())
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
const mockCan = vi.hoisted(() => vi.fn())
|
||||
const mockSetFilters = vi.hoisted(() => vi.fn())
|
||||
const mockFetch = vi.hoisted(() => vi.fn())
|
||||
const mockToastError = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('useHead', () => undefined)
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
|
||||
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||
|
||||
// Le repository est lui aussi un auto-import : on controle items + setFilters.
|
||||
vi.stubGlobal('useCarriersRepository', () => ({
|
||||
items: ref([
|
||||
{
|
||||
id: 7,
|
||||
name: 'TRANSPORTS ACME',
|
||||
certificationType: 'QUALIMAT',
|
||||
qualimatCarrier: { id: '42', name: 'TRANSPORTS ACME', validityDate: '2027-01-15', status: 'VALIDE' },
|
||||
updatedAt: '2026-01-15T10:00:00+00:00',
|
||||
isArchived: false,
|
||||
},
|
||||
]),
|
||||
totalItems: ref(1),
|
||||
currentPage: ref(1),
|
||||
itemsPerPage: ref(10),
|
||||
itemsPerPageOptions: ref([10, 25, 50]),
|
||||
fetch: mockFetch,
|
||||
goToPage: vi.fn(),
|
||||
setItemsPerPage: vi.fn(),
|
||||
setFilters: mockSetFilters,
|
||||
}))
|
||||
|
||||
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
|
||||
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
|
||||
globalThis.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
|
||||
const CarriersIndex = (await import('../carriers/index.vue')).default
|
||||
|
||||
// ── Stubs de composants ──────────────────────────────────────────────────────
|
||||
const ButtonStub = defineComponent({
|
||||
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||
emits: ['click'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||
},
|
||||
})
|
||||
|
||||
const DataTableStub = defineComponent({
|
||||
props: { items: { type: Array, default: () => [] } },
|
||||
emits: ['row-click', 'update:page', 'update:per-page'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('div', { 'data-testid': 'datatable' },
|
||||
(props.items as Array<{ id: number }>).map(it =>
|
||||
h('tr', { 'data-row-id': it.id, onClick: () => emit('row-click', it) }),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const DrawerStub = defineComponent({
|
||||
props: { modelValue: { type: Boolean, default: false } },
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()])
|
||||
},
|
||||
})
|
||||
|
||||
const SlotStub = defineComponent({ setup(_, { slots }) { return () => h('div', {}, slots.default?.()) } })
|
||||
|
||||
const PageHeaderStub = defineComponent({
|
||||
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
|
||||
})
|
||||
|
||||
const CheckboxStub = defineComponent({
|
||||
props: { id: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
|
||||
emits: ['update:model-value'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('input', {
|
||||
'type': 'checkbox',
|
||||
'data-id': props.id,
|
||||
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLInputElement).checked),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const InputTextStub = defineComponent({ setup() { return () => h('input') } })
|
||||
|
||||
function mountPage() {
|
||||
return mount(CarriersIndex, {
|
||||
global: {
|
||||
stubs: {
|
||||
PageHeader: PageHeaderStub,
|
||||
MalioButton: ButtonStub,
|
||||
MalioDataTable: DataTableStub,
|
||||
MalioDrawer: DrawerStub,
|
||||
MalioAccordion: SlotStub,
|
||||
MalioAccordionItem: SlotStub,
|
||||
MalioInputText: InputTextStub,
|
||||
MalioCheckbox: CheckboxStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('Répertoire transporteurs (page /carriers)', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockReset()
|
||||
mockApiGet.mockReset().mockResolvedValue({ member: [] })
|
||||
mockCan.mockReset().mockReturnValue(true)
|
||||
mockSetFilters.mockReset()
|
||||
mockFetch.mockReset()
|
||||
mockToastError.mockReset()
|
||||
})
|
||||
|
||||
it('charge la liste au montage', async () => {
|
||||
mountPage()
|
||||
await flushPromises()
|
||||
expect(mockFetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
|
||||
mockCan.mockImplementation((perm: string) => perm === 'transport.carriers.manage')
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-label="transport.carriers.add"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
|
||||
mockCan.mockImplementation((perm: string) => perm === 'transport.carriers.view')
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-label="transport.carriers.add"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('navigue vers la consultation au clic sur une ligne', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
await wrapper.find('tr[data-row-id="7"]').trigger('click')
|
||||
expect(mockPush).toHaveBeenCalledWith('/carriers/7')
|
||||
})
|
||||
|
||||
it('appelle l\'export XLSX sur /carriers/export.xlsx en blob', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-label="transport.carriers.export"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
'/carriers/export.xlsx',
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ responseType: 'blob', toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('repercute le filtre « Voir les archivés » dans setFilters sans toucher l\'URL', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
// Coche « Voir les archivés » puis applique les filtres.
|
||||
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
|
||||
await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click')
|
||||
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||
{ archivedOnly: true },
|
||||
{ replace: true },
|
||||
)
|
||||
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('repercute les certifications cochees dans setFilters (filtre multi)', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
// Coche deux certifications via les cases a cocher (pattern repertoire clients).
|
||||
await wrapper.find('input[data-id="filter-certification-QUALIMAT"]').setValue(true)
|
||||
await wrapper.find('input[data-id="filter-certification-AUTRE"]').setValue(true)
|
||||
await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click')
|
||||
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||
{ 'certificationType[]': ['QUALIMAT', 'AUTRE'] },
|
||||
{ replace: true },
|
||||
)
|
||||
})
|
||||
|
||||
it('badge filtres actifs + Réinitialiser vide l\'etat applique', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
|
||||
await wrapper.find('[data-label="transport.carriers.filters.apply"]').trigger('click')
|
||||
|
||||
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
|
||||
expect(wrapper.find('[data-label="transport.carriers.filters.title (1)"]').exists()).toBe(true)
|
||||
|
||||
// Réinitialiser → query propre (setFilters avec objet vide).
|
||||
await wrapper.find('[data-label="transport.carriers.filters.reset"]').trigger('click')
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,389 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('transport.carriers.title') }}
|
||||
<template #actions>
|
||||
<!-- gap-8 = 32px d'espacement entre Filtrer et Ajouter. -->
|
||||
<div class="flex items-center gap-8">
|
||||
<!-- Bouton Filtrer a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="tertiary"
|
||||
:label="filterButtonLabel"
|
||||
icon-name="mdi:tune"
|
||||
icon-position="left"
|
||||
icon-size="24"
|
||||
@click="openFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
variant="secondary"
|
||||
:label="t('transport.carriers.add')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="goToCreate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Datatable branchee sur usePaginatedList via useCarriersRepository :
|
||||
pagination serveur, tri name ASC par defaut (cote back). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
row-clickable
|
||||
:empty-message="t('transport.carriers.empty')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<!-- Certification : libelle i18n (le back renvoie le code enum). -->
|
||||
<template #cell-certificationType="{ item }">
|
||||
{{ formatCertification(item) }}
|
||||
</template>
|
||||
|
||||
<!-- Date de validite QUALIMAT : fond rouge si perimee (< aujourd'hui — RG-4.04). -->
|
||||
<template #cell-validityDate="{ item }">
|
||||
<span
|
||||
v-if="getValidityDate(item)"
|
||||
:class="isValidityExpired(item) ? 'inline-block rounded px-2 py-0.5 bg-m-danger text-white' : ''"
|
||||
>
|
||||
{{ formatDateFr(getValidityDate(item)) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Derniere activite : date de derniere modification (updatedAt). -->
|
||||
<template #cell-lastActivity="{ item }">
|
||||
{{ formatDateFr(item.updatedAt as string | null) }}
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<div class="flex justify-center mt-4">
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="primary"
|
||||
:label="t('transport.carriers.export')"
|
||||
:disabled="exporting"
|
||||
@click="exportXlsx"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
|
||||
« Voir les résultats ». Meme pattern que les repertoires M1/M2/M3.
|
||||
Etat 100 % local, jamais dans l'URL (regle ABSOLUE n°6). -->
|
||||
<MalioDrawer
|
||||
v-model="filterDrawerOpen"
|
||||
drawer-class="max-w-[450px]"
|
||||
body-class="p-0"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold uppercase">{{ t('transport.carriers.filters.title') }}</h2>
|
||||
</template>
|
||||
|
||||
<MalioAccordion>
|
||||
<!-- Recherche : nom du transporteur (param `search`). -->
|
||||
<MalioAccordionItem :title="t('transport.carriers.filters.search')" value="search">
|
||||
<MalioInputText
|
||||
v-model="draftSearch"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Certification : cases a cocher (multi). Valeur = code enum.
|
||||
Meme pattern que le filtre Categories du repertoire clients. -->
|
||||
<MalioAccordionItem :title="t('transport.carriers.filters.certification')" value="certification">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in certificationOptions"
|
||||
:id="`filter-certification-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftCertificationTypes.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleCertification(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Statut : voir uniquement les archives (sinon actifs uniquement). -->
|
||||
<MalioAccordionItem :title="t('transport.carriers.filters.status')" value="status">
|
||||
<MalioCheckbox
|
||||
id="filter-archived-only"
|
||||
:label="t('transport.carriers.filters.archivedOnly')"
|
||||
:model-value="draftArchivedOnly"
|
||||
@update:model-value="(val: boolean) => draftArchivedOnly = val"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('transport.carriers.filters.reset')"
|
||||
button-class="w-m-btn-action"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('transport.carriers.filters.apply')"
|
||||
button-class="w-[170px]"
|
||||
@click="applyFilters"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
interface FilterOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('transport.carriers.title') })
|
||||
|
||||
// Bouton « Ajouter » reserve a `manage` (Admin + Bureau). « Exporter » et
|
||||
// « Filtrer » suivent `view` (Admin / Bureau / Commerciale). Compta et Usine
|
||||
// n'ont aucun acces (item sidebar masque cote back).
|
||||
const canManage = computed(() => can('transport.carriers.manage'))
|
||||
const canView = computed(() => can('transport.carriers.view'))
|
||||
|
||||
const {
|
||||
items: carriers,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadCarriers,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
setFilters,
|
||||
} = useCarriersRepository()
|
||||
|
||||
// Mappe les transporteurs en objets « plats » pour MalioDataTable (items typees
|
||||
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
|
||||
// implicite, contrairement a l'interface Carrier. Meme pattern que M1/M2/M3.
|
||||
const rows = computed(() => carriers.value.map(carrier => ({
|
||||
id: carrier.id,
|
||||
name: carrier.name,
|
||||
certificationType: carrier.certificationType,
|
||||
validityDate: carrier.qualimatCarrier?.validityDate ?? null,
|
||||
updatedAt: carrier.updatedAt,
|
||||
})))
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: t('transport.carriers.column.name') },
|
||||
{ key: 'certificationType', label: t('transport.carriers.column.certification') },
|
||||
{ key: 'validityDate', label: t('transport.carriers.column.validityDate') },
|
||||
{ key: 'lastActivity', label: t('transport.carriers.column.lastActivity') },
|
||||
]
|
||||
|
||||
// Codes de certification (miroir de l'enum back) + cas LIOT (null). Le libelle
|
||||
// est resolu par i18n ; un code inconnu retombe sur le code brut.
|
||||
const CERTIFICATION_CODES = ['QUALIMAT', 'GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'] as const
|
||||
|
||||
const certificationOptions = computed<FilterOption[]>(() =>
|
||||
CERTIFICATION_CODES.map(code => ({
|
||||
value: code,
|
||||
label: t(`transport.carriers.certification.${code}`),
|
||||
})),
|
||||
)
|
||||
|
||||
/** Libelle i18n de la certification (vide en cas LIOT — certificationType null). */
|
||||
function formatCertification(item: Record<string, unknown>): string {
|
||||
const code = item.certificationType as string | null | undefined
|
||||
if (!code) {
|
||||
return ''
|
||||
}
|
||||
return t(`transport.carriers.certification.${code}`)
|
||||
}
|
||||
|
||||
/** Date de validite QUALIMAT de la ligne (null si transporteur non QUALIMAT). */
|
||||
function getValidityDate(item: Record<string, unknown>): string | null {
|
||||
return (item.validityDate as string | null | undefined) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.04 : un agrement QUALIMAT est perime si sa date de validite est anterieure
|
||||
* a la date du jour (comparaison jour a jour, sans l'heure).
|
||||
*/
|
||||
function isValidityExpired(item: Record<string, unknown>): boolean {
|
||||
const value = getValidityDate(item)
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return false
|
||||
}
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date.getTime() < today.getTime()
|
||||
}
|
||||
|
||||
/** Format court francais JJ-MM-AAAA (spec M4). Chaine vide si date absente / invalide. */
|
||||
function formatDateFr(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
return `${day}-${month}-${date.getFullYear()}`
|
||||
}
|
||||
|
||||
/** Clic sur une ligne → ecran Consultation (route a plat /carriers/{id}). */
|
||||
function onRowClick(item: Record<string, unknown>): void {
|
||||
router.push(`/carriers/${item.id}`)
|
||||
}
|
||||
|
||||
function goToCreate(): void {
|
||||
router.push('/carriers/new')
|
||||
}
|
||||
|
||||
// ── Filtres (drawer) ────────────────────────────────────────────────────────
|
||||
// Deux niveaux d'etat (pattern repertoires M1/M2/M3) :
|
||||
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
|
||||
// uniquement au clic « Voir les résultats » / « Réinitialiser ».
|
||||
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
|
||||
const filterDrawerOpen = ref(false)
|
||||
|
||||
const draftSearch = ref('')
|
||||
const draftCertificationTypes = ref<string[]>([])
|
||||
const draftArchivedOnly = ref(false)
|
||||
|
||||
const appliedSearch = ref('')
|
||||
const appliedCertificationTypes = ref<string[]>([])
|
||||
const appliedArchivedOnly = ref(false)
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
let count = 0
|
||||
if (appliedSearch.value.trim() !== '') count++
|
||||
if (appliedCertificationTypes.value.length > 0) count++
|
||||
if (appliedArchivedOnly.value) count++
|
||||
return count
|
||||
})
|
||||
|
||||
const filterButtonLabel = computed(() => {
|
||||
const base = t('transport.carriers.filters.title')
|
||||
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
|
||||
})
|
||||
|
||||
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la reouverture
|
||||
// reflete les filtres actifs.
|
||||
function openFilters(): void {
|
||||
draftSearch.value = appliedSearch.value
|
||||
draftCertificationTypes.value = [...appliedCertificationTypes.value]
|
||||
draftArchivedOnly.value = appliedArchivedOnly.value
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
/** Coche / decoche une certification dans le brouillon (filtre multi). */
|
||||
function toggleCertification(code: string, selected: boolean): void {
|
||||
draftCertificationTypes.value = selected
|
||||
? [...draftCertificationTypes.value, code]
|
||||
: draftCertificationTypes.value.filter(c => c !== code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le payload de filtres serveur a partir de l'etat applique. Cle
|
||||
* `certificationType[]` pour que PHP la parse en tableau (OR cote back). Les
|
||||
* filtres vides sont omis pour une query propre.
|
||||
*/
|
||||
function buildFilterPayload(): Record<string, string | string[] | boolean> {
|
||||
const payload: Record<string, string | string[] | boolean> = {}
|
||||
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
||||
if (appliedCertificationTypes.value.length > 0) payload['certificationType[]'] = [...appliedCertificationTypes.value]
|
||||
if (appliedArchivedOnly.value) payload.archivedOnly = true
|
||||
return payload
|
||||
}
|
||||
|
||||
// « Voir les résultats » : recopie brouillon → applied, pousse les filtres
|
||||
// (retombe en page 1 via usePaginatedList) et ferme le drawer.
|
||||
function applyFilters(): void {
|
||||
appliedSearch.value = draftSearch.value.trim()
|
||||
appliedCertificationTypes.value = [...draftCertificationTypes.value]
|
||||
appliedArchivedOnly.value = draftArchivedOnly.value
|
||||
|
||||
setFilters(buildFilterPayload(), { replace: true })
|
||||
filterDrawerOpen.value = false
|
||||
}
|
||||
|
||||
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
|
||||
// Le drawer reste ouvert pour montrer le formulaire vide.
|
||||
function resetFilters(): void {
|
||||
draftSearch.value = ''
|
||||
draftCertificationTypes.value = []
|
||||
draftArchivedOnly.value = false
|
||||
|
||||
appliedSearch.value = ''
|
||||
appliedCertificationTypes.value = []
|
||||
appliedArchivedOnly.value = false
|
||||
|
||||
setFilters({}, { replace: true })
|
||||
}
|
||||
|
||||
// ── Export XLSX ─────────────────────────────────────────────────────────────
|
||||
// Memes filtres que la vue : l'export reflete exactement ce que l'utilisateur voit.
|
||||
const exporting = ref(false)
|
||||
|
||||
async function exportXlsx(): Promise<void> {
|
||||
if (exporting.value) {
|
||||
return
|
||||
}
|
||||
exporting.value = true
|
||||
try {
|
||||
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
|
||||
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
|
||||
// contenu faute d'overload blob sur le client partage (meme pattern M2/M3).
|
||||
const blob = await api.get<Blob>('/carriers/export.xlsx', buildFilterPayload(), {
|
||||
responseType: 'blob',
|
||||
toast: false,
|
||||
} as unknown as Parameters<typeof api.get>[2])
|
||||
|
||||
triggerDownload(blob, 'repertoire-transporteurs.xlsx')
|
||||
}
|
||||
catch {
|
||||
toast.error({
|
||||
title: t('transport.carriers.toast.error'),
|
||||
message: t('transport.carriers.toast.exportError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Declenche le telechargement d'un blob via un lien temporaire. */
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCarriers()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete : retour vers le repertoire + titre. -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
v-bind="{ ariaLabel: t('transport.carriers.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('transport.carriers.form.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- ── Formulaire principal (pre-onglets) ─────────────────────────────
|
||||
Sans validation de ce bloc, les onglets restent inaccessibles. Au
|
||||
succes du POST, les champs passent en lecture seule et on bascule
|
||||
automatiquement sur l'onglet Qualimat. Les champs conditionnels
|
||||
(indexation / benne / volume si affrete, decharge si AUTRE, cas LIOT)
|
||||
et la saisie assistee QUALIMAT arrivent a ERP-166. -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="main.name"
|
||||
:label="t('transport.carriers.form.main.name')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:error="mainErrors.errors.name"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="main.certificationType"
|
||||
:options="certificationOptions"
|
||||
:label="t('transport.carriers.form.main.certificationType')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:error="mainErrors.errors.certificationType"
|
||||
@update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)"
|
||||
/>
|
||||
<!-- Wrapper h-12 + centrage vertical : aligne la case a cocher sur la
|
||||
ligne de champ des inputs/selects (qui posent un h-12 items-center
|
||||
en interne). reserve-message-space=false pour un centrage exact. -->
|
||||
<div class="flex h-12 items-center">
|
||||
<MalioCheckbox
|
||||
id="carrier-is-chartered"
|
||||
:label="t('transport.carriers.form.main.isChartered')"
|
||||
:model-value="main.isChartered"
|
||||
:readonly="mainLocked"
|
||||
:reserve-message-space="false"
|
||||
@update:model-value="(val: boolean) => main.isChartered = val"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!mainLocked" class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('transport.carriers.form.submit')"
|
||||
:disabled="mainSubmitting"
|
||||
@click="onSubmitMain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets a validation incrementale ─────────────────────────────
|
||||
Barre Qualimat · Adresses · Contacts · Prix. Onglets verrouilles tant
|
||||
que le formulaire principal n'est pas valide (unlockedIndex = -1) puis
|
||||
deverrouilles progressivement. Le contenu de chaque onglet arrive aux
|
||||
tickets suivants (ERP-166+) : placeholders « A venir » pour l'instant. -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||
<template
|
||||
v-for="key in tabKeys"
|
||||
:key="key"
|
||||
#[key]
|
||||
>
|
||||
<div class="mt-12 flex justify-center text-m-muted">
|
||||
{{ t('transport.carriers.form.comingSoon') }}
|
||||
</div>
|
||||
</template>
|
||||
</MalioTabList>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
||||
|
||||
interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('transport.carriers.form.title') })
|
||||
|
||||
// Gating de la route : la creation est reservee a `manage` (Admin / Bureau).
|
||||
// Commerciale (consultation seule), Compta et Usine sont rediriges vers le repertoire.
|
||||
if (!can('transport.carriers.manage')) {
|
||||
await navigateTo('/carriers')
|
||||
}
|
||||
|
||||
const {
|
||||
main,
|
||||
mainLocked,
|
||||
mainSubmitting,
|
||||
mainErrors,
|
||||
tabKeys,
|
||||
activeTab,
|
||||
unlockedIndex,
|
||||
submitMain,
|
||||
} = useCarrierForm()
|
||||
|
||||
// Certifications selectionnables manuellement (spec § Formulaire principal).
|
||||
// QUALIMAT n'est PAS dans cette liste : il est pose par la saisie assistee QUALIMAT
|
||||
// (ERP-166), pas choisi a la main.
|
||||
const SELECTABLE_CERTIFICATIONS = ['GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'] as const
|
||||
|
||||
const certificationOptions = computed<SelectOption[]>(() =>
|
||||
SELECTABLE_CERTIFICATIONS.map(code => ({
|
||||
value: code,
|
||||
label: t(`transport.carriers.certification.${code}`),
|
||||
})),
|
||||
)
|
||||
|
||||
// Icone (Iconify) affichee dans chaque onglet, par cle.
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
qualimat: 'mdi:truck-check-outline',
|
||||
addresses: 'mdi:map-marker-outline',
|
||||
contacts: 'mdi:account-box-plus-outline',
|
||||
prices: 'mdi:currency-eur',
|
||||
}
|
||||
|
||||
// Onglets desactives tant que le formulaire principal n'est pas valide
|
||||
// (unlockedIndex = -1 au depart) ; deverrouillage progressif ensuite.
|
||||
const tabs = computed(() => tabKeys.value.map((key, index) => ({
|
||||
key,
|
||||
label: t(`transport.carriers.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
disabled: index > unlockedIndex.value,
|
||||
})))
|
||||
|
||||
/** Retour vers le repertoire transporteurs (fleche d'en-tete). */
|
||||
function goBack(): void {
|
||||
router.push('/carriers')
|
||||
}
|
||||
|
||||
/** Valide le formulaire principal (POST /carriers ; bascule gerée par le composable). */
|
||||
async function onSubmitMain(): Promise<void> {
|
||||
await submitMain()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Types du workflow « Ajouter un transporteur » (M4 Transport, ERP-165).
|
||||
*
|
||||
* Périmètre ERP-165 = formulaire PRINCIPAL (pré-onglets) uniquement : Nom +
|
||||
* Certification + Affréter. Les champs conditionnels (indexation / benne / volume
|
||||
* si affrété, décharge si AUTRE, immatriculations LIOT) et la saisie assistée
|
||||
* QUALIMAT arrivent à ERP-166 ; les onglets Adresses / Contacts / Prix aux tickets
|
||||
* suivants. On garde donc volontairement ce draft minimal — il s'étendra.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Brouillon du formulaire principal. `certificationType` est un code enum back
|
||||
* (GMP_PLUS | OVOCOM | COMPTE_PROPRE | AUTRE ; QUALIMAT sera posé par la saisie
|
||||
* assistée à ERP-166) ou `null` tant que rien n'est choisi.
|
||||
*/
|
||||
export interface CarrierMainDraft {
|
||||
name: string
|
||||
certificationType: string | null
|
||||
isChartered: boolean
|
||||
}
|
||||
|
||||
/** Brouillon principal vide (état initial du formulaire de création). */
|
||||
export function emptyCarrierMain(): CarrierMainDraft {
|
||||
return {
|
||||
name: '',
|
||||
certificationType: null,
|
||||
isChartered: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Réponse du POST / PATCH principal (groupe `carrier:read`). Le serveur renvoie
|
||||
* le nom normalisé (UPPERCASE, RG-4.13) que l'UI réaffiche tel quel.
|
||||
*/
|
||||
export interface CarrierMainResponse {
|
||||
id: number
|
||||
name: string | null
|
||||
certificationType: string | null
|
||||
'@id'?: string
|
||||
}
|
||||
Generated
+10
-10
@@ -7,7 +7,7 @@
|
||||
"name": "starseed-frontend",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.7.10",
|
||||
"@malio/layer-ui": "^1.7.12",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -583,9 +583,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz",
|
||||
"integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==",
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -594,9 +594,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
|
||||
"integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -1866,9 +1866,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.7.10",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.10/layer-ui-1.7.10.tgz",
|
||||
"integrity": "sha512-ZWYaKvl+VpGAqeTE+4xdyKOmuRd4zwjlUYVppeIBZwGeNAK16kZnrztR+4eQmnzUqPZVybBhEBdKP9weqWHSUg==",
|
||||
"version": "1.7.12",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.12/layer-ui-1.7.12.tgz",
|
||||
"integrity": "sha512-ezQLqi19K2ogI3XwSMsUyluU9x5C4W0tu1muxFbL3foKjibRYRg/FdvySivEhEsalAAt1E88V6Sv/06xPqyYTw==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.7.10",
|
||||
"@malio/layer-ui": "^1.7.12",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -95,6 +95,14 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
'technique.providers.archive',
|
||||
// Transport — Repertoire transporteurs (M4, ERP-164). Meme logique :
|
||||
// mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE
|
||||
// n°7). L'item transporteurs vit desormais dans la section Administration
|
||||
// (1er item, ERP-164) mais sur la route `/carriers` (hors `/admin/<slug>`),
|
||||
// donc il n'entre pas dans ALL_ADMIN_LINKS : expectedAdminLinks reste inchange.
|
||||
'transport.carriers.view',
|
||||
'transport.carriers.manage',
|
||||
'transport.carriers.archive',
|
||||
],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||
},
|
||||
|
||||
@@ -232,6 +232,7 @@ test-db-setup:
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_carrier_name_active ON carrier (LOWER(name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
|
||||
fixtures:
|
||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||
@@ -258,6 +259,14 @@ seed-rbac:
|
||||
qualimat-sync:
|
||||
$(SYMFONY_CONSOLE) --no-interaction app:qualimat:sync
|
||||
|
||||
# Synchronise le referentiel des codes IDTF (ERP-149) depuis l'export Excel
|
||||
# icrt-idtf.com : upsert sur (schema, idtf_number) + soft-delete + journal.
|
||||
# Idempotent (refresh complet).
|
||||
# Options : --schema=road|water (defaut road), --dry-run (analyse sans
|
||||
# ecriture), --file=<chemin.xlsx> (source locale au lieu du telechargement).
|
||||
idtf-sync:
|
||||
$(SYMFONY_CONSOLE) --no-interaction app:idtf:sync
|
||||
|
||||
# Attention, supprime votre bdd local
|
||||
db-reset:
|
||||
$(DOCKER_COMPOSE) down -v
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-149 (Module Transport) : referentiel des codes IDTF (regimes de nettoyage
|
||||
* transport).
|
||||
*
|
||||
* Tables alimentees par la commande `app:idtf:sync` (parsing de l'export Excel
|
||||
* icrt-idtf.com, upsert sur (schema, idtf_number) + soft-delete + journal).
|
||||
* Aucune FK cross-module : migration au namespace racine `DoctrineMigrations`.
|
||||
*/
|
||||
final class Version20260612160000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-149 : tables idtf_product + idtf_sync_log (referentiel codes IDTF, synchro console depuis l\'export Excel).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE idtf_product (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
idtf_number INTEGER NOT NULL,
|
||||
schema VARCHAR(8) NOT NULL,
|
||||
product_group VARCHAR(255) DEFAULT NULL,
|
||||
name TEXT NOT NULL,
|
||||
cleaning_regime VARCHAR(64) NOT NULL,
|
||||
important_requirements TEXT DEFAULT NULL,
|
||||
mandatory_date DATE DEFAULT NULL,
|
||||
related_products TEXT DEFAULT NULL,
|
||||
formula VARCHAR(255) DEFAULT NULL,
|
||||
eural_code VARCHAR(64) DEFAULT NULL,
|
||||
cas_numbers JSONB DEFAULT '[]' NOT NULL,
|
||||
footnotes TEXT DEFAULT NULL,
|
||||
source_export_date DATE NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
last_synced_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT uq_idtf_product_schema_number UNIQUE (schema, idtf_number),
|
||||
CONSTRAINT chk_idtf_product_schema CHECK (schema IN ('road', 'water'))
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_idtf_product_active ON idtf_product (schema, is_active)');
|
||||
|
||||
$this->comment('idtf_product', '_table', "Referentiel des codes IDTF (marchandise + regime de nettoyage transport), synchronise depuis l'export Excel icrt-idtf.com.");
|
||||
$this->comment('idtf_product', 'id', 'Cle technique auto-incrementee.');
|
||||
$this->comment('idtf_product', 'idtf_number', 'Numero IDTF de la marchandise (identifiant metier source). Unique par schema.');
|
||||
$this->comment('idtf_product', 'schema', "Mode de transport / schema IDTF : 'road' (routier) ou 'water' (fluvial). Discriminant d'unicite avec idtf_number.");
|
||||
$this->comment('idtf_product', 'product_group', "Groupe de produit (colonne Product Group de l'export). Nullable.");
|
||||
$this->comment('idtf_product', 'name', "Nom de la marchandise (libelle FR de l'export).");
|
||||
$this->comment('idtf_product', 'cleaning_regime', 'Regime de nettoyage minimal exige (A, B, C, Interdit, ...).');
|
||||
$this->comment('idtf_product', 'important_requirements', 'Exigences importantes associees. Nullable.');
|
||||
$this->comment('idtf_product', 'mandatory_date', "Date d'application obligatoire du regime (convertie depuis dd-mm-yyyy). Nullable.");
|
||||
$this->comment('idtf_product', 'related_products', 'Produits apparentes (texte libre). Nullable.');
|
||||
$this->comment('idtf_product', 'formula', 'Formule chimique de la marchandise. Nullable.');
|
||||
$this->comment('idtf_product', 'eural_code', 'Code EURAL (dechet) associe. Nullable.');
|
||||
$this->comment('idtf_product', 'cas_numbers', 'Liste des numeros CAS (JSONB), eclatee depuis la cellule "Numero CAS" separee par ";". Tableau vide si absent.');
|
||||
$this->comment('idtf_product', 'footnotes', "Annotations / notes de bas de page de l'export. Nullable.");
|
||||
$this->comment('idtf_product', 'source_export_date', 'Date d\'export du fichier source (preambule "Export date:").');
|
||||
$this->comment('idtf_product', 'is_active', 'Faux = ligne absente du dernier export (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.');
|
||||
$this->comment('idtf_product', 'last_synced_at', 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE idtf_sync_log (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
schema VARCHAR(8) NOT NULL,
|
||||
export_date DATE NOT NULL,
|
||||
rows_total INT NOT NULL,
|
||||
rows_upserted INT NOT NULL,
|
||||
rows_deactivated INT NOT NULL,
|
||||
created_at TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW() NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->comment('idtf_sync_log', '_table', 'Journal des synchronisations IDTF (une ligne par run de la commande app:idtf:sync).');
|
||||
$this->comment('idtf_sync_log', 'id', 'Cle technique auto-incrementee.');
|
||||
$this->comment('idtf_sync_log', 'schema', "Mode de transport synchronise : 'road' ou 'water'.");
|
||||
$this->comment('idtf_sync_log', 'export_date', "Date d'export du fichier source traite par ce run.");
|
||||
$this->comment('idtf_sync_log', 'rows_total', 'Nombre de lignes exploitables lues dans le fichier.');
|
||||
$this->comment('idtf_sync_log', 'rows_upserted', 'Nombre de lignes inserees ou mises a jour.');
|
||||
$this->comment('idtf_sync_log', 'rows_deactivated', 'Nombre de lignes passees a is_active=false (absentes de cet export).');
|
||||
$this->comment('idtf_sync_log', 'created_at', 'Horodatage de fin du run (insertion du journal).');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS idtf_sync_log');
|
||||
$this->addSql('DROP TABLE IF EXISTS idtf_product');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pose un COMMENT ON TABLE/COLUMN en dollar-quoting Postgres ($_$...$_$)
|
||||
* pour eviter tout echappement d'apostrophes dans les descriptions.
|
||||
*/
|
||||
private function comment(string $table, string $column, string $description): void
|
||||
{
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-154 — Infra d'upload de fichiers generique et reutilisable (src/Shared).
|
||||
*
|
||||
* Cree la table `uploaded_document` : reference technique d'un fichier televerse
|
||||
* (PDF / image), gere par le service Shared\Infrastructure\Upload\FileUploader.
|
||||
* La « Decharge » du M4 transporteurs en sera le premier consommateur, mais ce
|
||||
* ticket ne touche AUCUN module : la table vit cote Shared.
|
||||
*
|
||||
* Caracteristiques :
|
||||
* - Document IMMUABLE : pas d'onglet edition, pas de updated_at / updated_by.
|
||||
* Seules les colonnes created_at (UTC, remplie par le FileUploader via
|
||||
* l'horloge injectee) et created_by (auteur HTTP, null hors HTTP) tracent
|
||||
* l'origine. C'est pourquoi l'entite Shared n'implemente PAS
|
||||
* Timestampable/Blamable (qui imposeraient les 4 colonnes).
|
||||
* - checksum sha256 (64 caracteres hex) : controle d'integrite + future
|
||||
* deduplication eventuelle (hors scope ici).
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et non
|
||||
* modulaire : la table porte une FK cross-module vers "user" (created_by). Le
|
||||
* tri par version au sein du namespace racine garantit qu'elle joue APRES la
|
||||
* creation de "user" sur base vide.
|
||||
*
|
||||
* Style DDL aligne sur le M1/M2/M3 : `INT GENERATED BY DEFAULT AS IDENTITY` et
|
||||
* `TIMESTAMP(0) WITHOUT TIME ZONE` (mapping ORM `datetime_immutable`), pour que
|
||||
* `schema:update --force` reste un no-op une fois l'entite mappee.
|
||||
*
|
||||
* COMMENT ON COLUMN inline (regle ABSOLUE n°12) : chaque colonne porte sa
|
||||
* description ici. La table est aussi ajoutee a `ColumnCommentsCatalog` car
|
||||
* l'entite UploadedDocument existe des ce ticket — `app:apply-column-comments`
|
||||
* du `test-db-setup` rejoue donc ces COMMENT apres le `schema:update --force`.
|
||||
*/
|
||||
final class Version20260615130000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-154 : table uploaded_document (infra upload generique Shared) — fichier televerse immuable, checksum sha256.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE uploaded_document (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
original_filename VARCHAR(255) NOT NULL,
|
||||
stored_path VARCHAR(512) NOT NULL,
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
size_bytes INT NOT NULL,
|
||||
checksum VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_uploaded_document_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
// Postgres n'indexe pas automatiquement les colonnes de FK.
|
||||
$this->addSql('CREATE INDEX idx_uploaded_document_created_by ON uploaded_document (created_by)');
|
||||
// Recherche d'integrite / future deduplication par empreinte sha256.
|
||||
$this->addSql('CREATE INDEX idx_uploaded_document_checksum ON uploaded_document (checksum)');
|
||||
|
||||
$this->addSql('COMMENT ON TABLE uploaded_document IS $_$Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.id IS $_$Identifiant interne auto-incremente.$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.original_filename IS $_$Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.stored_path IS $_$Chemin relatif du fichier sous var/uploads (ex: 2026/06/<hash>.pdf) — nom genere aleatoirement, jamais le nom client.$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.mime_type IS $_$Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.size_bytes IS $_$Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.checksum IS $_$Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.created_at IS $_$Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN uploaded_document.created_by IS $_$ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.$_$');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS uploaded_document');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* M4 — Repertoire transporteurs (ERP-155/157) : creation du schema BDD du
|
||||
* repertoire transporteurs sous le module Transport (jumeau des M2/M3).
|
||||
*
|
||||
* Tables creees :
|
||||
* - carrier : table principale (formulaire + lien QUALIMAT + archive + soft-delete
|
||||
* + Timestampable/Blamable) ;
|
||||
* - carrier_address / carrier_contact / carrier_price : sous-collections 1:n.
|
||||
*
|
||||
* Tables NON recrees (reutilisees) :
|
||||
* - qualimat_carrier (ERP-39, Version20260612150000) : cible de la FK editable
|
||||
* carrier.qualimat_carrier_id (§ 2.5) ;
|
||||
* - uploaded_document (ERP-154, Version20260615130000) : cible de la FK
|
||||
* carrier.discharge_document_id (Decharge, § 2.7) ;
|
||||
* - client / client_address / supplier / supplier_address (M1/M2) et site (Sites) :
|
||||
* cibles des FK de carrier_price (onglet Prix, RG-4.10/4.11).
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
|
||||
* FK cross-module (user, client, client_address, supplier, supplier_address, site,
|
||||
* qualimat_carrier, uploaded_document). Le tri par timestamp au sein du namespace
|
||||
* racine garantit l'ordre apres la creation de ces tables sur base vide.
|
||||
*
|
||||
* Decision IDs (spec § 2.2, tranchee a ce ticket) : carrier et ses sous-tables
|
||||
* utilisent `INT GENERATED BY DEFAULT AS IDENTITY` (homogeneite globale Starseed
|
||||
* M1/M2/M3, evite la friction bigint->string de l'ORM). Seule
|
||||
* carrier.qualimat_carrier_id est BIGINT pour matcher qualimat_carrier.id (existant).
|
||||
* Horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le TimestampableBlamableTrait mappe
|
||||
* `datetime_immutable`), pour que `schema:update --force` reste un no-op.
|
||||
*
|
||||
* Chaque colonne porte son `COMMENT ON COLUMN` (regle ABSOLUE n°12). Les 4
|
||||
* tables carrier* etant mappees par l'ORM des ce ticket, elles sont aussi ajoutees
|
||||
* a ColumnCommentsCatalog : `app:apply-column-comments` (test-db-setup) rejoue ces
|
||||
* COMMENT apres le `schema:update --force` qui les droperait sinon.
|
||||
*/
|
||||
final class Version20260615150000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-155/157 (M4) : tables carrier + carrier_address + carrier_contact + carrier_price (repertoire transporteurs).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->createCarrierTable();
|
||||
$this->createCarrierAddress();
|
||||
$this->createCarrierContact();
|
||||
$this->createCarrierPrice();
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Ordre inverse des dependances FK : sous-collections d'abord, puis carrier.
|
||||
$this->addSql('DROP TABLE IF EXISTS carrier_price');
|
||||
$this->addSql('DROP TABLE IF EXISTS carrier_contact');
|
||||
$this->addSql('DROP TABLE IF EXISTS carrier_address');
|
||||
$this->addSql('DROP TABLE IF EXISTS carrier');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Table principale `carrier`
|
||||
// =================================================================
|
||||
|
||||
private function createCarrierTable(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE carrier (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
qualimat_carrier_id BIGINT DEFAULT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
certification_type VARCHAR(20) DEFAULT NULL,
|
||||
is_chartered BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
indexation_rate NUMERIC(5, 2) DEFAULT NULL,
|
||||
container_type VARCHAR(12) DEFAULT NULL,
|
||||
volume_m3 NUMERIC(10, 2) DEFAULT NULL,
|
||||
discharge_document_id INT DEFAULT NULL,
|
||||
liot_plates TEXT DEFAULT NULL,
|
||||
is_archived BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_carrier_certification_type
|
||||
CHECK (certification_type IS NULL OR certification_type IN ('QUALIMAT', 'GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE')),
|
||||
CONSTRAINT chk_carrier_container_type
|
||||
CHECK (container_type IS NULL OR container_type IN ('BENNE', 'FOND_MOUVANT')),
|
||||
CONSTRAINT fk_carrier_qualimat
|
||||
FOREIGN KEY (qualimat_carrier_id) REFERENCES qualimat_carrier (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_carrier_discharge_document
|
||||
FOREIGN KEY (discharge_document_id) REFERENCES uploaded_document (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_carrier_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_carrier_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_carrier_is_archived ON carrier (is_archived)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_deleted_at ON carrier (deleted_at)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_qualimat ON carrier (qualimat_carrier_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_discharge_document ON carrier (discharge_document_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_created_by ON carrier (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_updated_by ON carrier (updated_by)');
|
||||
|
||||
// Unicite metier partielle : nom insensible a la casse, parmi les
|
||||
// non-archives ET non soft-deletes uniquement (§ 2.6). Inexprimable en ORM.
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE UNIQUE INDEX uq_carrier_name_active
|
||||
ON carrier (LOWER(name))
|
||||
WHERE is_archived = FALSE AND deleted_at IS NULL
|
||||
SQL);
|
||||
|
||||
$this->comment('carrier', '_table', 'Repertoire transporteurs (M4 Transport) — entites editables, archivables (is_archived) et soft-deletables (deleted_at). Distinct du referentiel qualimat_carrier.');
|
||||
$this->comment('carrier', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('carrier', 'qualimat_carrier_id', 'Lien editable vers le referentiel QUALIMAT (saisie assistee RG-4.01). FK -> qualimat_carrier.id, ON DELETE SET NULL : transporteur conserve si la ligne QUALIMAT disparait.');
|
||||
$this->comment('carrier', 'name', 'Raison sociale du transporteur (stockee en MAJUSCULES). Unique case-insensitive parmi les non-archives/non-supprimes (uq_carrier_name_active, RG-4.12 / § 2.6).');
|
||||
$this->comment('carrier', 'certification_type', 'Type de certification : QUALIMAT (si lie, lecture seule) ou GMP_PLUS/OVOCOM/COMPTE_PROPRE/AUTRE. AUTRE declenche le champ Decharge (RG-4.02). Null en cas LIOT (RG-4.01).');
|
||||
$this->comment('carrier', 'is_chartered', '« Affreter » coche : declenche indexation/benne-fond mouvant/volume, obligatoires (RG-4.03). Faux par defaut.');
|
||||
$this->comment('carrier', 'indexation_rate', 'Taux d indexation en pourcentage (NUMERIC 5,2) — renseigne si affrete (RG-4.03).');
|
||||
$this->comment('carrier', 'container_type', 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_container_type) — renseigne si affrete (RG-4.03).');
|
||||
$this->comment('carrier', 'volume_m3', 'Volume en m3 (NUMERIC 10,2) — renseigne si affrete (RG-4.03).');
|
||||
$this->comment('carrier', 'discharge_document_id', 'Document de Decharge (visible si certification_type = AUTRE, RG-4.02). FK -> uploaded_document.id (infra Shared § 2.7), ON DELETE SET NULL.');
|
||||
$this->comment('carrier', 'liot_plates', 'Immatriculations LIOT separees par « ; » (cas special nom=LIOT, RG-4.01). Les autres champs sont masques dans ce cas.');
|
||||
$this->comment('carrier', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission transport.carriers.archive (Admin seul).');
|
||||
$this->comment('carrier', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.');
|
||||
$this->comment('carrier', 'deleted_at', 'Horodatage du soft-delete technique — non expose par l API au M4. Null = ligne active.');
|
||||
$this->addTimestampableBlamableComments('carrier');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : adresses (1:n)
|
||||
// =================================================================
|
||||
|
||||
private function createCarrierAddress(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE carrier_address (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
carrier_id INT NOT NULL,
|
||||
country VARCHAR(80) DEFAULT 'France' NOT NULL,
|
||||
postal_code VARCHAR(20) DEFAULT NULL,
|
||||
city VARCHAR(120) DEFAULT NULL,
|
||||
street VARCHAR(255) DEFAULT NULL,
|
||||
street_complement VARCHAR(255) DEFAULT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_carrier_address_carrier
|
||||
FOREIGN KEY (carrier_id) REFERENCES carrier (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_carrier_address_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_carrier_address_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_carrier_address_carrier ON carrier_address (carrier_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_address_created_by ON carrier_address (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_address_updated_by ON carrier_address (updated_by)');
|
||||
|
||||
$this->comment('carrier_address', '_table', 'Adresses d un transporteur (1:n) — onglet Adresse (M4). Pre-remplie depuis QUALIMAT si applicable (RG-4.05).');
|
||||
$this->comment('carrier_address', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('carrier_address', 'carrier_id', 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire de l adresse.');
|
||||
$this->comment('carrier_address', 'country', 'Pays de l adresse — defaut France.');
|
||||
$this->comment('carrier_address', 'postal_code', 'Code postal (saisie assistee BAN cote front, RG-4.06).');
|
||||
$this->comment('carrier_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front.');
|
||||
$this->comment('carrier_address', 'street', 'Numero et voie de l adresse.');
|
||||
$this->comment('carrier_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.');
|
||||
$this->comment('carrier_address', 'position', 'Ordre d affichage de l adresse dans la liste du transporteur (croissant).');
|
||||
$this->addTimestampableBlamableComments('carrier_address');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : contacts (1:n)
|
||||
// =================================================================
|
||||
|
||||
private function createCarrierContact(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE carrier_contact (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
carrier_id INT NOT NULL,
|
||||
first_name VARCHAR(120) DEFAULT NULL,
|
||||
last_name VARCHAR(120) DEFAULT NULL,
|
||||
job_title VARCHAR(120) DEFAULT NULL,
|
||||
phone_primary VARCHAR(20) DEFAULT NULL,
|
||||
phone_secondary VARCHAR(20) DEFAULT NULL,
|
||||
email VARCHAR(180) DEFAULT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_carrier_contact_filled
|
||||
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL
|
||||
OR phone_primary IS NOT NULL OR email IS NOT NULL),
|
||||
CONSTRAINT fk_carrier_contact_carrier
|
||||
FOREIGN KEY (carrier_id) REFERENCES carrier (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_carrier_contact_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_carrier_contact_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_carrier_contact_carrier ON carrier_contact (carrier_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_contact_created_by ON carrier_contact (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_contact_updated_by ON carrier_contact (updated_by)');
|
||||
|
||||
$this->comment('carrier_contact', '_table', 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins un champ rempli (RG-4.08, chk_carrier_contact_filled), max 2 telephones.');
|
||||
$this->comment('carrier_contact', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('carrier_contact', 'carrier_id', 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.');
|
||||
$this->comment('carrier_contact', 'first_name', 'Prenom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).');
|
||||
$this->comment('carrier_contact', 'last_name', 'Nom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).');
|
||||
$this->comment('carrier_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).');
|
||||
$this->comment('carrier_contact', 'phone_primary', 'Telephone principal — chiffres uniquement (normalisation serveur).');
|
||||
$this->comment('carrier_contact', 'phone_secondary', 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).');
|
||||
$this->comment('carrier_contact', 'email', 'Email du contact (lowercase serveur).');
|
||||
$this->comment('carrier_contact', 'position', 'Ordre d affichage du contact dans la liste du transporteur (croissant).');
|
||||
$this->addTimestampableBlamableComments('carrier_contact');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : prix (1:n) — onglet Prix (RG-4.09 -> RG-4.11)
|
||||
// =================================================================
|
||||
|
||||
private function createCarrierPrice(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE carrier_price (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
carrier_id INT NOT NULL,
|
||||
direction VARCHAR(12) NOT NULL,
|
||||
client_id INT DEFAULT NULL,
|
||||
client_delivery_address_id INT DEFAULT NULL,
|
||||
departure_site_id INT DEFAULT NULL,
|
||||
supplier_id INT DEFAULT NULL,
|
||||
supplier_supply_address_id INT DEFAULT NULL,
|
||||
delivery_site_id INT DEFAULT NULL,
|
||||
container_type VARCHAR(12) NOT NULL,
|
||||
pricing_unit VARCHAR(8) NOT NULL,
|
||||
price NUMERIC(12, 2) NOT NULL,
|
||||
price_state VARCHAR(12) NOT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_carrier_price_direction CHECK (direction IN ('CLIENT', 'FOURNISSEUR')),
|
||||
CONSTRAINT chk_carrier_price_container CHECK (container_type IN ('BENNE', 'FOND_MOUVANT')),
|
||||
CONSTRAINT chk_carrier_price_unit CHECK (pricing_unit IN ('FORFAIT', 'TONNE')),
|
||||
CONSTRAINT chk_carrier_price_state CHECK (price_state IN ('EN_COURS', 'VALIDE', 'NON_VALIDE')),
|
||||
CONSTRAINT chk_carrier_price_client_branch
|
||||
CHECK (direction <> 'CLIENT' OR (client_id IS NOT NULL AND supplier_id IS NULL)),
|
||||
CONSTRAINT chk_carrier_price_supplier_branch
|
||||
CHECK (direction <> 'FOURNISSEUR' OR (supplier_id IS NOT NULL AND client_id IS NULL)),
|
||||
CONSTRAINT fk_carrier_price_carrier
|
||||
FOREIGN KEY (carrier_id) REFERENCES carrier (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_carrier_price_client
|
||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_carrier_price_client_address
|
||||
FOREIGN KEY (client_delivery_address_id) REFERENCES client_address (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_carrier_price_departure_site
|
||||
FOREIGN KEY (departure_site_id) REFERENCES site (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_carrier_price_supplier
|
||||
FOREIGN KEY (supplier_id) REFERENCES supplier (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_carrier_price_supplier_address
|
||||
FOREIGN KEY (supplier_supply_address_id) REFERENCES supplier_address (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_carrier_price_delivery_site
|
||||
FOREIGN KEY (delivery_site_id) REFERENCES site (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_carrier_price_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_carrier_price_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_carrier_price_carrier ON carrier_price (carrier_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_price_client ON carrier_price (client_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_price_client_address ON carrier_price (client_delivery_address_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_price_departure_site ON carrier_price (departure_site_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_price_supplier ON carrier_price (supplier_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_price_supplier_address ON carrier_price (supplier_supply_address_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_price_delivery_site ON carrier_price (delivery_site_id)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_price_created_by ON carrier_price (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_carrier_price_updated_by ON carrier_price (updated_by)');
|
||||
|
||||
$this->comment('carrier_price', '_table', 'Prix d un transporteur (1:n) — onglet Prix (M4). Branche CLIENT ou FOURNISSEUR selon direction (RG-4.09→4.11, CHECK chk_carrier_price_*).');
|
||||
$this->comment('carrier_price', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('carrier_price', 'carrier_id', 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du prix.');
|
||||
$this->comment('carrier_price', 'direction', 'Sens du prix : CLIENT ou FOURNISSEUR (RG-4.09). Pilote l affichage et l obligation des colonnes client_*/supplier_* (RG-4.10/4.11).');
|
||||
$this->comment('carrier_price', 'client_id', 'Branche CLIENT (RG-4.10) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi direction = CLIENT.');
|
||||
$this->comment('carrier_price', 'client_delivery_address_id', 'Branche CLIENT : adresse de livraison du client. FK -> client_address.id, ON DELETE RESTRICT.');
|
||||
$this->comment('carrier_price', 'departure_site_id', 'Branche CLIENT : adresse de depart = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.');
|
||||
$this->comment('carrier_price', 'supplier_id', 'Branche FOURNISSEUR (RG-4.11) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi direction = FOURNISSEUR.');
|
||||
$this->comment('carrier_price', 'supplier_supply_address_id', 'Branche FOURNISSEUR : adresse d approvisionnement du fournisseur. FK -> supplier_address.id, ON DELETE RESTRICT.');
|
||||
$this->comment('carrier_price', 'delivery_site_id', 'Branche FOURNISSEUR : adresse de livraison = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.');
|
||||
$this->comment('carrier_price', 'container_type', 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_price_container).');
|
||||
$this->comment('carrier_price', 'pricing_unit', 'Unite de tarification FORFAIT|TONNE (chk_carrier_price_unit).');
|
||||
$this->comment('carrier_price', 'price', 'Montant du prix (NUMERIC 12,2).');
|
||||
$this->comment('carrier_price', 'price_state', 'Etat du prix : EN_COURS, VALIDE ou NON_VALIDE (chk_carrier_price_state). Affiche dans le tableau Prix.');
|
||||
$this->comment('carrier_price', 'position', 'Ordre d affichage du prix dans la liste du transporteur (croissant).');
|
||||
$this->addTimestampableBlamableComments('carrier_price');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Helpers (identiques au M2 Version20260605130000)
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
|
||||
* en reutilisant le catalogue partage (source unique, ERP-67).
|
||||
*/
|
||||
private function addTimestampableBlamableComments(string $table): void
|
||||
{
|
||||
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
|
||||
$this->comment($table, $column, $description);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN`
|
||||
* en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement d apostrophe.
|
||||
*/
|
||||
private function comment(string $table, string $column, string $description): void
|
||||
{
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\ClientInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
@@ -147,7 +148,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Index(name: 'idx_client_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_client_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class Client implements TimestampableInterface, BlamableInterface
|
||||
class Client implements TimestampableInterface, BlamableInterface, ClientInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepositor
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\ClientAddressInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
@@ -89,7 +90,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Table(name: 'client_address')]
|
||||
#[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])]
|
||||
#[Auditable]
|
||||
class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
class ClientAddress implements TimestampableInterface, BlamableInterface, ClientAddressInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SupplierInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
@@ -142,7 +143,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Index(name: 'idx_supplier_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_supplier_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class Supplier implements TimestampableInterface, BlamableInterface
|
||||
class Supplier implements TimestampableInterface, BlamableInterface, SupplierInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SupplierAddressInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -96,7 +97,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Table(name: 'supplier_address')]
|
||||
#[ORM\Index(name: 'idx_supplier_address_supplier', columns: ['supplier_id'])]
|
||||
#[Auditable]
|
||||
class SupplierAddress implements TimestampableInterface, BlamableInterface
|
||||
class SupplierAddress implements TimestampableInterface, BlamableInterface, SupplierAddressInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
@@ -117,7 +118,11 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['supplier:item:read'])]
|
||||
// supplier_address:read : groupe additif consomme par l'embed cross-module
|
||||
// (CarrierPrice.supplierSupplyAddress, M4 § 3.4). Inerte pour M2 (ses contextes
|
||||
// ne l'incluent pas) — expose le libelle d'adresse quand un autre module embarque
|
||||
// une SupplierAddress.
|
||||
#[Groups(['supplier:item:read', 'supplier_address:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Supplier::class, inversedBy: 'addresses')]
|
||||
@@ -130,12 +135,12 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\NotBlank(message: 'Le type d\'adresse est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Choice(choices: self::ADDRESS_TYPES, message: 'Le type d\'adresse doit être Prospect, Départ ou Rendu.')]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private ?string $addressType = null;
|
||||
|
||||
#[ORM\Column(length: 80, options: ['default' => 'France'])]
|
||||
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private string $country = 'France';
|
||||
|
||||
// RG-2.05 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur).
|
||||
@@ -143,24 +148,24 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\NotBlank(message: 'Le code postal est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private ?string $postalCode = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private ?string $streetComplement = null;
|
||||
|
||||
// Specifique fournisseur : nombre de bennes sur le site.
|
||||
|
||||
@@ -51,9 +51,9 @@ final class RbacSeeder
|
||||
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
|
||||
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
|
||||
* attacher (admin n'apparait pas car il bypass tout via isAdmin ;
|
||||
* `commercial.clients.archive`, `commercial.suppliers.archive` et
|
||||
* `technique.providers.archive` ne sont attaches a aucun role metier —
|
||||
* admin seul).
|
||||
* `commercial.clients.archive`, `commercial.suppliers.archive`,
|
||||
* `technique.providers.archive` et `transport.carriers.archive` ne sont
|
||||
* attaches a aucun role metier — admin seul).
|
||||
*
|
||||
* Cloisonnement par site des prestataires (M3 § 2.13) : la permission
|
||||
* `sites.bypass_scope` est attribuee par defaut a Bureau / Compta /
|
||||
@@ -77,6 +77,9 @@ final class RbacSeeder
|
||||
// Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite).
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
// Transporteurs (M4 § 5.2, ERP-153) : view + manage (PAS archive -> admin seul).
|
||||
'transport.carriers.view',
|
||||
'transport.carriers.manage',
|
||||
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
|
||||
'sites.bypass_scope',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
@@ -120,6 +123,9 @@ final class RbacSeeder
|
||||
// (onglet Comptabilite masque/filtre pour la Commerciale).
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
// Transporteurs (M4 § 5.2, ERP-153) : view seul (consultation « Tout »,
|
||||
// ni manage ni archive pour la Commerciale).
|
||||
'transport.carriers.view',
|
||||
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
|
||||
'sites.bypass_scope',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
|
||||
@@ -212,6 +212,11 @@ final class SeedE2ECommand extends Command
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
'technique.providers.archive',
|
||||
// Transport — Repertoire transporteurs (M4, ERP-153). Meme
|
||||
// logique : mappe sur le persona "tout". Miroir de personas.ts.
|
||||
'transport.carriers.view',
|
||||
'transport.carriers.manage',
|
||||
'transport.carriers.archive',
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Application\Service;
|
||||
|
||||
/**
|
||||
* Normalisation serveur des champs texte d'un Carrier / CarrierContact, appliquee
|
||||
* par le CarrierProcessor (et les futurs processors de sous-ressources, WT6/7/8)
|
||||
* AVANT persistance. Cf. spec-back M4 § 2.10 + RG-4.13. Jumeau de
|
||||
* SupplierFieldNormalizer (M2), enrichi du cas LIOT (immatriculations).
|
||||
*
|
||||
* - name : UPPERCASE integral (RG-4.13)
|
||||
* - firstName / lastName (personnes, sur CarrierContact) : Title Case (RG-4.13)
|
||||
* - phone* : chiffres uniquement, ex "06.12.34.56.78" -> "0612345678" (RG-4.13).
|
||||
* Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front.
|
||||
* - email : lowercase integral (RG-4.13)
|
||||
* - liotPlates : liste « ; » -> split, trim, UPPER, rejoin "; " (cas LIOT RG-4.01).
|
||||
*
|
||||
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide apres
|
||||
* trim devient null (evite de persister "" dans des colonnes nullable).
|
||||
*/
|
||||
final class CarrierFieldNormalizer
|
||||
{
|
||||
/**
|
||||
* Raison sociale en majuscules (RG-4.13). Conserve null tel quel ; une chaine
|
||||
* non vide est trim + upper. Une chaine vide reste "" (champ obligatoire :
|
||||
* c'est l'Assert\NotBlank qui rejette, pas le normalizer).
|
||||
*/
|
||||
public function normalizeName(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mb_strtoupper(trim($value), 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nom/prenom de personne en Title Case (RG-4.13) : "JEAN dupont" ->
|
||||
* "Jean Dupont". Une chaine vide apres trim devient null.
|
||||
*/
|
||||
public function normalizePersonName(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Email en minuscules (RG-4.13). Une chaine vide apres trim devient null.
|
||||
*/
|
||||
public function normalizeEmail(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Telephone reduit aux chiffres (RG-4.13) : "06.12.34.56.78" -> "0612345678".
|
||||
* Une valeur sans aucun chiffre devient null.
|
||||
*/
|
||||
public function normalizePhone(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D+/', '', $value) ?? '';
|
||||
|
||||
return '' === $digits ? null : $digits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immatriculations LIOT (RG-4.01 / RG-4.13) : la saisie « ; »-separee est
|
||||
* decoupee, chaque plaque trim + UPPER, les segments vides ecartes, puis
|
||||
* recomposee avec le separateur canonique "; ". Une saisie sans aucune plaque
|
||||
* exploitable devient null.
|
||||
*/
|
||||
public function normalizeLiotPlates(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$plates = [];
|
||||
foreach (explode(';', $value) as $plate) {
|
||||
$plate = trim($plate);
|
||||
if ('' !== $plate) {
|
||||
$plates[] = mb_strtoupper($plate, 'UTF-8');
|
||||
}
|
||||
}
|
||||
|
||||
return [] === $plates ? null : implode('; ', $plates);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierProcessor;
|
||||
use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\CarrierProvider;
|
||||
use App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Entity\UploadedDocument;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* Transporteur (M4 Transport) — entite racine du repertoire transporteurs,
|
||||
* jumelle de Supplier (M2) / Provider (M3). Porte le formulaire principal, le
|
||||
* lien editable vers le referentiel QUALIMAT (§ 2.5), l'archivage
|
||||
* (is_archived / archived_at) et le soft-delete technique prepare mais non
|
||||
* expose au M4 (deleted_at).
|
||||
*
|
||||
* Perimetre WT4 (ERP-158) = formulaire principal en ecriture. L'#[ApiResource]
|
||||
* expose desormais Post + Patch (via CarrierProcessor : normalisation RG-4.13,
|
||||
* gating archive mode strict RG-4.14, 409 doublon de nom RG-4.12) en plus du
|
||||
* contrat de lecture pose au WT3. Les proprietes du formulaire principal portent
|
||||
* leur groupe d'ecriture (carrier:write:main / carrier:write:archive) et leurs
|
||||
* contraintes Assert ; les RG conditionnelles (RG-4.01 certification obligatoire
|
||||
* sauf LIOT, RG-4.02 AUTRE -> decharge, RG-4.03 affrete -> indexation/benne/volume)
|
||||
* sont portees par validateMainFormConsistency (Assert\Callback + ->atPath()).
|
||||
* Les sous-ressources d'ecriture (adresses/contacts/prix) arrivent aux worktrees
|
||||
* suivants (WT6/7/8). Les invariants BDD (NOT NULL, CHECK enum, FK, unicite
|
||||
* partielle) restent garantis par la migration Version20260615150000.
|
||||
*
|
||||
* Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) :
|
||||
* - LISTE (carrier:read + qualimat:read + default:read) : name, certificationType,
|
||||
* qualimatCarrier (statut/validite — RG-4.04), updatedAt.
|
||||
* - DETAIL (+ carrier:item:read + embeds client/supplier/site...) : sous-collections
|
||||
* addresses / contacts / prices embarquees, avec les entites cross-module
|
||||
* (Client/Supplier/Site/adresses) serialisees via leurs read-groups.
|
||||
*
|
||||
* Pas de #[ORM\UniqueConstraint] : l'unicite du nom (RG-4.12) est portee par
|
||||
* l'index partiel fonctionnel uq_carrier_name_active (LOWER(name) WHERE
|
||||
* is_archived = FALSE AND deleted_at IS NULL), inexprimable en attribut ORM.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('transport.carriers.view')",
|
||||
// Liste : embarque qualimatCarrier (ManyToOne, fetch-join sur cette
|
||||
// seule relation cote repository — § 2.11) pour le statut/date de
|
||||
// validite QUALIMAT (RG-4.04). Aucune sous-collection en liste.
|
||||
normalizationContext: ['groups' => ['carrier:read', 'qualimat:read', 'default:read']],
|
||||
provider: CarrierProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('transport.carriers.view')",
|
||||
// Detail : transporteur + qualimatCarrier + sous-collections embarquees
|
||||
// (addresses / contacts / prices). Les relations cross-module des prix
|
||||
// (client / supplier / sites / adresses) sont embarquees via leurs
|
||||
// read-groups (client:read / supplier:read / ... — bugs #1/#2 M1).
|
||||
normalizationContext: ['groups' => [
|
||||
'carrier:read',
|
||||
'carrier:item:read',
|
||||
'qualimat:read',
|
||||
'client:read',
|
||||
'client_address:read',
|
||||
'supplier:read',
|
||||
'supplier_address:read',
|
||||
'site:read',
|
||||
'default:read',
|
||||
]],
|
||||
provider: CarrierProvider::class,
|
||||
),
|
||||
new Post(
|
||||
// Creation du formulaire principal (RG-4.01 -> RG-4.03 / RG-4.12 /
|
||||
// RG-4.13). La reponse 201 ne renvoie que les scalaires principaux +
|
||||
// id : le front enchaine ensuite les sous-ressources par onglet.
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => ['carrier:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['carrier:write:main']],
|
||||
processor: CarrierProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
// Security elargie au seul `manage` (Admin + Bureau). Le CarrierProcessor
|
||||
// re-gate ensuite l'archivage : un payload basculant isArchived exige
|
||||
// `transport.carriers.archive` (Admin seul -> Bureau recoit 403, mode
|
||||
// strict RG-4.14).
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => ['carrier:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['carrier:write:main', 'carrier:write:archive']],
|
||||
provider: CarrierProvider::class,
|
||||
processor: CarrierProcessor::class,
|
||||
),
|
||||
// Pas de Delete au M4 (HP-M4-C). Archivage via PATCH { isArchived: true }.
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineCarrierRepository::class)]
|
||||
#[ORM\Table(name: 'carrier')]
|
||||
#[ORM\Index(name: 'idx_carrier_is_archived', columns: ['is_archived'])]
|
||||
#[ORM\Index(name: 'idx_carrier_deleted_at', columns: ['deleted_at'])]
|
||||
#[ORM\Index(name: 'idx_carrier_qualimat', columns: ['qualimat_carrier_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_discharge_document', columns: ['discharge_document_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_carrier_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class Carrier implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
/** RG-4.01 : nom du cas special « compte-propre » LIOT (comparaison insensible a la casse). */
|
||||
private const string LIOT_NAME = 'LIOT';
|
||||
|
||||
/** RG-4.02 : valeur de certification imposant le champ Decharge. */
|
||||
private const string CERTIFICATION_AUTRE = 'AUTRE';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['carrier:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'Le nom du transporteur est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(
|
||||
min: 2,
|
||||
max: 255,
|
||||
minMessage: 'Le nom du transporteur doit contenir au moins {{ limit }} caractères.',
|
||||
maxMessage: 'Le nom du transporteur ne peut dépasser {{ limit }} caractères.',
|
||||
normalizer: 'trim',
|
||||
)]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $name = null;
|
||||
|
||||
/** Lien editable vers le referentiel QUALIMAT (saisie assistee RG-4.01, § 2.5). */
|
||||
#[ORM\ManyToOne(targetEntity: QualimatCarrier::class)]
|
||||
#[ORM\JoinColumn(name: 'qualimat_carrier_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?QualimatCarrier $qualimatCarrier = null;
|
||||
|
||||
/** QUALIMAT|GMP_PLUS|OVOCOM|COMPTE_PROPRE|AUTRE ; null en cas LIOT (RG-4.01). */
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
#[Assert\Choice(
|
||||
choices: ['QUALIMAT', 'GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'],
|
||||
message: 'Type de certification invalide.',
|
||||
)]
|
||||
// Obligatoire SAUF en cas LIOT (champ masque) : controle conditionnel dans
|
||||
// validateMainFormConsistency (RG-4.01). La longueur est bornee par le Choice
|
||||
// (whitelist EntityConstraintsHaveFrenchMessageTest::EXCLUDED_LENGTH_MIRROR).
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $certificationType = null;
|
||||
|
||||
#[ORM\Column(name: 'is_chartered', options: ['default' => false])]
|
||||
#[Groups(['carrier:write:main'])]
|
||||
private bool $isChartered = false;
|
||||
|
||||
/** % d'indexation — obligatoire si affrete (RG-4.03, validateMainFormConsistency). */
|
||||
#[ORM\Column(name: 'indexation_rate', type: 'decimal', precision: 5, scale: 2, nullable: true)]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $indexationRate = null;
|
||||
|
||||
/** BENNE|FOND_MOUVANT — obligatoire si affrete (RG-4.03). */
|
||||
#[ORM\Column(name: 'container_type', length: 12, nullable: true)]
|
||||
#[Assert\Choice(choices: ['BENNE', 'FOND_MOUVANT'], message: 'Type de contenant invalide.')]
|
||||
// Longueur bornee par le Choice (whitelist EXCLUDED_LENGTH_MIRROR).
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $containerType = null;
|
||||
|
||||
/** Volume m3 — obligatoire si affrete (RG-4.03). */
|
||||
#[ORM\Column(name: 'volume_m3', type: 'decimal', precision: 10, scale: 2, nullable: true)]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $volumeM3 = null;
|
||||
|
||||
/** Decharge (upload, visible si certificationType = AUTRE — RG-4.02). Infra Shared (§ 2.7). */
|
||||
#[ORM\ManyToOne(targetEntity: UploadedDocument::class)]
|
||||
#[ORM\JoinColumn(name: 'discharge_document_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?UploadedDocument $dischargeDocument = null;
|
||||
|
||||
/** Immatriculations LIOT separees par « ; » (cas LIOT — RG-4.01). */
|
||||
#[ORM\Column(name: 'liot_plates', type: 'text', nullable: true)]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $liotPlates = null;
|
||||
|
||||
// === Sous-collections — EMBARQUEES dans le DETAIL (read-group sur le getter) ===
|
||||
/** @var Collection<int, CarrierAddress> */
|
||||
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $addresses;
|
||||
|
||||
/** @var Collection<int, CarrierContact> */
|
||||
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $contacts;
|
||||
|
||||
/** @var Collection<int, CarrierPrice> */
|
||||
#[ORM\OneToMany(mappedBy: 'carrier', targetEntity: CarrierPrice::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $prices;
|
||||
|
||||
// === Archive / Soft delete ===
|
||||
// Le groupe de LECTURE est declare sur le getter isArchived() avec
|
||||
// SerializedName('isArchived') (piege booleen #3 M1) ; le groupe d'ECRITURE
|
||||
// vit sur la propriete pour que la denormalisation cible setIsArchived.
|
||||
#[ORM\Column(name: 'is_archived', options: ['default' => false])]
|
||||
#[Groups(['carrier:write:archive'])]
|
||||
private bool $isArchived = false;
|
||||
|
||||
#[ORM\Column(name: 'archived_at', type: 'datetime_immutable', nullable: true)]
|
||||
#[Groups(['carrier:read'])]
|
||||
private ?DateTimeImmutable $archivedAt = null;
|
||||
|
||||
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
|
||||
private ?DateTimeImmutable $deletedAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->addresses = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
$this->prices = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Coherence conditionnelle du formulaire principal (RG-4.01 / RG-4.02 /
|
||||
* RG-4.03). Decision figee (miroir M2 RG-2.07/2.08) : ces RG inter-champs
|
||||
* passent par une contrainte d'entite (Assert\Callback + ->atPath()) et NON par
|
||||
* le CarrierProcessor, afin que chaque 422 porte un propertyPath exploitable
|
||||
* par useFormErrors (mapping inline sous le champ, pas un toast — ERP-101).
|
||||
* Jouee par API Platform AVANT le processor, sur POST comme sur PATCH.
|
||||
*
|
||||
* Cas LIOT (RG-4.01) : seul liotPlates est pertinent ; les autres champs sont
|
||||
* masques cote front et le back ne les valide pas (« stocke ce qu'il recoit,
|
||||
* pas de 422 sur la presence residuelle »). Le nom est compare en majuscules
|
||||
* car la normalisation UPPER n'intervient qu'au processor (apres validation).
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateMainFormConsistency(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (self::LIOT_NAME === mb_strtoupper(trim((string) $this->name), 'UTF-8')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// RG-4.01 : certification obligatoire hors cas LIOT.
|
||||
if (null === $this->certificationType || '' === $this->certificationType) {
|
||||
$context->buildViolation('Le type de certification est obligatoire.')
|
||||
->atPath('certificationType')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
// RG-4.02 : certification AUTRE -> decharge obligatoire.
|
||||
if (self::CERTIFICATION_AUTRE === $this->certificationType && null === $this->dischargeDocument) {
|
||||
$context->buildViolation('La décharge est obligatoire pour une certification « Autre ».')
|
||||
->atPath('dischargeDocument')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
// RG-4.03 : affrete -> indexation + benne/fond mouvant + volume obligatoires.
|
||||
if ($this->isChartered) {
|
||||
if (null === $this->indexationRate) {
|
||||
$context->buildViolation('Le taux d\'indexation est obligatoire pour un transporteur affrété.')
|
||||
->atPath('indexationRate')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
if (null === $this->containerType) {
|
||||
$context->buildViolation('Le type de contenant est obligatoire pour un transporteur affrété.')
|
||||
->atPath('containerType')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
if (null === $this->volumeM3) {
|
||||
$context->buildViolation('Le volume est obligatoire pour un transporteur affrété.')
|
||||
->atPath('volumeM3')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getQualimatCarrier(): ?QualimatCarrier
|
||||
{
|
||||
return $this->qualimatCarrier;
|
||||
}
|
||||
|
||||
public function setQualimatCarrier(?QualimatCarrier $qualimatCarrier): static
|
||||
{
|
||||
$this->qualimatCarrier = $qualimatCarrier;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCertificationType(): ?string
|
||||
{
|
||||
return $this->certificationType;
|
||||
}
|
||||
|
||||
public function setCertificationType(?string $certificationType): static
|
||||
{
|
||||
$this->certificationType = $certificationType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// Boolean trap (RETEX M1 bug #3) : #[Groups] + #[SerializedName] sur le getter,
|
||||
// sinon Symfony strip le prefixe "is" et drope la cle du JSON.
|
||||
#[Groups(['carrier:read'])]
|
||||
#[SerializedName('isChartered')]
|
||||
public function isChartered(): bool
|
||||
{
|
||||
return $this->isChartered;
|
||||
}
|
||||
|
||||
public function setIsChartered(bool $isChartered): static
|
||||
{
|
||||
$this->isChartered = $isChartered;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIndexationRate(): ?string
|
||||
{
|
||||
return $this->indexationRate;
|
||||
}
|
||||
|
||||
public function setIndexationRate(?string $indexationRate): static
|
||||
{
|
||||
$this->indexationRate = $indexationRate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContainerType(): ?string
|
||||
{
|
||||
return $this->containerType;
|
||||
}
|
||||
|
||||
public function setContainerType(?string $containerType): static
|
||||
{
|
||||
$this->containerType = $containerType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVolumeM3(): ?string
|
||||
{
|
||||
return $this->volumeM3;
|
||||
}
|
||||
|
||||
public function setVolumeM3(?string $volumeM3): static
|
||||
{
|
||||
$this->volumeM3 = $volumeM3;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDischargeDocument(): ?UploadedDocument
|
||||
{
|
||||
return $this->dischargeDocument;
|
||||
}
|
||||
|
||||
public function setDischargeDocument(?UploadedDocument $dischargeDocument): static
|
||||
{
|
||||
$this->dischargeDocument = $dischargeDocument;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLiotPlates(): ?string
|
||||
{
|
||||
return $this->liotPlates;
|
||||
}
|
||||
|
||||
public function setLiotPlates(?string $liotPlates): static
|
||||
{
|
||||
$this->liotPlates = $liotPlates;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CarrierAddress> */
|
||||
#[Groups(['carrier:item:read'])]
|
||||
public function getAddresses(): Collection
|
||||
{
|
||||
return $this->addresses;
|
||||
}
|
||||
|
||||
public function addAddress(CarrierAddress $address): static
|
||||
{
|
||||
if (!$this->addresses->contains($address)) {
|
||||
$this->addresses->add($address);
|
||||
$address->setCarrier($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeAddress(CarrierAddress $address): static
|
||||
{
|
||||
if ($this->addresses->removeElement($address) && $address->getCarrier() === $this) {
|
||||
$address->setCarrier(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CarrierContact> */
|
||||
#[Groups(['carrier:item:read'])]
|
||||
public function getContacts(): Collection
|
||||
{
|
||||
return $this->contacts;
|
||||
}
|
||||
|
||||
public function addContact(CarrierContact $contact): static
|
||||
{
|
||||
if (!$this->contacts->contains($contact)) {
|
||||
$this->contacts->add($contact);
|
||||
$contact->setCarrier($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeContact(CarrierContact $contact): static
|
||||
{
|
||||
if ($this->contacts->removeElement($contact) && $contact->getCarrier() === $this) {
|
||||
$contact->setCarrier(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CarrierPrice> */
|
||||
#[Groups(['carrier:item:read'])]
|
||||
public function getPrices(): Collection
|
||||
{
|
||||
return $this->prices;
|
||||
}
|
||||
|
||||
public function addPrice(CarrierPrice $price): static
|
||||
{
|
||||
if (!$this->prices->contains($price)) {
|
||||
$this->prices->add($price);
|
||||
$price->setCarrier($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removePrice(CarrierPrice $price): static
|
||||
{
|
||||
if ($this->prices->removeElement($price) && $price->getCarrier() === $this) {
|
||||
$price->setCarrier(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// Boolean trap (cf. isChartered) : groupe de lecture + SerializedName sur le getter.
|
||||
#[Groups(['carrier:read'])]
|
||||
#[SerializedName('isArchived')]
|
||||
public function isArchived(): bool
|
||||
{
|
||||
return $this->isArchived;
|
||||
}
|
||||
|
||||
public function setIsArchived(bool $isArchived): static
|
||||
{
|
||||
$this->isArchived = $isArchived;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getArchivedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->archivedAt;
|
||||
}
|
||||
|
||||
public function setArchivedAt(?DateTimeImmutable $archivedAt): static
|
||||
{
|
||||
$this->archivedAt = $archivedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->deletedAt;
|
||||
}
|
||||
|
||||
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
|
||||
{
|
||||
$this->deletedAt = $deletedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierAddressProcessor;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Adresse d'un transporteur (1:n) — onglet Adresse (M4). Jumelle de
|
||||
* SupplierAddress (M2), version simplifiee (pas de type d'adresse, pas de M2M
|
||||
* sites/categories sur l'adresse : les sites du M4 vivent dans l'onglet Prix).
|
||||
*
|
||||
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
|
||||
* transporteur). Ecriture : groupe `carrier:write:addresses`.
|
||||
*
|
||||
* Sous-ressource API (ERP-159, spec § 4.5) — jumelle de SupplierAddress (M2) /
|
||||
* ProviderAddress (M3), sans address_type ni M2M (les sites du M4 vivent dans
|
||||
* l'onglet Prix) :
|
||||
* - POST /api/carriers/{carrierId}/addresses : creation rattachee au
|
||||
* transporteur parent (Link toProperty 'carrier'), security
|
||||
* transport.carriers.manage.
|
||||
* - PATCH / DELETE /api/carrier_addresses/{id} : security
|
||||
* transport.carriers.manage.
|
||||
* - GET /api/carrier_addresses/{id} : lecture unitaire (security view) — la
|
||||
* lecture courante reste via le parent. Pas de GET collection autonome.
|
||||
* Tout passe par le CarrierAddressProcessor (rattachement parent + RG-4.05).
|
||||
*
|
||||
* Regles de l'onglet Adresse :
|
||||
* - RG-4.06 : code postal a 4 ou 5 chiffres (Assert\Regex ; pas de controle
|
||||
* CP/ville serveur, l'autocomplete BAN est front).
|
||||
* - RG-4.05 : si le transporteur est affrete (isChartered), l'adresse devient
|
||||
* obligatoire (Pays / CP / Ville / Adresse) — validation conditionnelle portee
|
||||
* par le CarrierAddressProcessor (le parent n'est pas disponible a la
|
||||
* validation Symfony sur un POST sous-ressource en read:false).
|
||||
* - RG-4.07 : masquage du bouton « Valider » si QUALIMAT = front ; le back
|
||||
* accepte le PATCH normalement (aucune garde back specifique).
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable / Blamable.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('transport.carriers.view')",
|
||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/carriers/{carrierId}/addresses',
|
||||
uriVariables: [
|
||||
'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'),
|
||||
],
|
||||
// read:false : pas de stade lecture du parent. Le Link toProperty
|
||||
// resoudrait l'enfant (SELECT CarrierAddress ... WHERE carrier = :id)
|
||||
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
||||
// manuellement par CarrierAddressProcessor::linkParent (404 si absent).
|
||||
read: false,
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['carrier:write:addresses']],
|
||||
processor: CarrierAddressProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['carrier:write:addresses']],
|
||||
processor: CarrierAddressProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
processor: CarrierAddressProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'carrier_address')]
|
||||
#[ORM\Index(name: 'idx_carrier_address_carrier', columns: ['carrier_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_address_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_carrier_address_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class CarrierAddress implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'addresses')]
|
||||
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Carrier $carrier = null;
|
||||
|
||||
#[ORM\Column(length: 80, options: ['default' => 'France'])]
|
||||
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
private string $country = 'France';
|
||||
|
||||
// RG-4.06 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur,
|
||||
// l'autocomplete BAN est front). Le Regex borne deja la longueur (<= 5) : pas
|
||||
// de Length redondant (whitelist EXCLUDED_LENGTH_MIRROR). Nullable : obligatoire
|
||||
// seulement si affrete (RG-4.05, garde CarrierAddressProcessor).
|
||||
#[ORM\Column(name: 'postal_code', length: 20, nullable: true)]
|
||||
#[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
private ?string $postalCode = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'L\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(name: 'street_complement', length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
private ?string $streetComplement = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCarrier(): ?Carrier
|
||||
{
|
||||
return $this->carrier;
|
||||
}
|
||||
|
||||
public function setCarrier(?Carrier $carrier): static
|
||||
{
|
||||
$this->carrier = $carrier;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCountry(): string
|
||||
{
|
||||
return $this->country;
|
||||
}
|
||||
|
||||
public function setCountry(string $country): static
|
||||
{
|
||||
$this->country = $country;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPostalCode(): ?string
|
||||
{
|
||||
return $this->postalCode;
|
||||
}
|
||||
|
||||
public function setPostalCode(?string $postalCode): static
|
||||
{
|
||||
$this->postalCode = $postalCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCity(): ?string
|
||||
{
|
||||
return $this->city;
|
||||
}
|
||||
|
||||
public function setCity(?string $city): static
|
||||
{
|
||||
$this->city = $city;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStreet(): ?string
|
||||
{
|
||||
return $this->street;
|
||||
}
|
||||
|
||||
public function setStreet(?string $street): static
|
||||
{
|
||||
$this->street = $street;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStreetComplement(): ?string
|
||||
{
|
||||
return $this->streetComplement;
|
||||
}
|
||||
|
||||
public function setStreetComplement(?string $streetComplement): static
|
||||
{
|
||||
$this->streetComplement = $streetComplement;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierContactProcessor;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Contact d'un transporteur (1:n) — onglet Contact (M4). Jumeau de
|
||||
* SupplierContact (M2) : au moins un champ rempli (RG-4.08, garanti par le
|
||||
* CHECK chk_carrier_contact_filled + le Processor), max 2 telephones.
|
||||
*
|
||||
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
|
||||
* transporteur). Ecriture : groupe `carrier:write:contacts`.
|
||||
*
|
||||
* Sous-ressource API (ERP-160, spec § 4.5) — jumelle de SupplierContact (M2) :
|
||||
* - POST /api/carriers/{carrierId}/contacts : creation rattachee au
|
||||
* transporteur parent (Link toProperty 'carrier'), security
|
||||
* transport.carriers.manage.
|
||||
* - PATCH / DELETE /api/carrier_contacts/{id} : security
|
||||
* transport.carriers.manage.
|
||||
* - GET /api/carrier_contacts/{id} : lecture unitaire (security view) — la
|
||||
* lecture courante reste via le parent. Pas de GET collection autonome.
|
||||
* Tout passe par le CarrierContactProcessor (rattachement parent + RG-4.08 +
|
||||
* RG-4.13).
|
||||
*
|
||||
* Telephones (RG-4.08, max 2) : le contrat d'ecriture expose un tableau virtuel
|
||||
* `phones` (liste dynamique cote front « x1, +1 possible, max 2 ») mappe par le
|
||||
* Processor vers `phonePrimary` / `phoneSecondary` (un 3e numero -> 422). Les
|
||||
* deux colonnes scalaires restent en lecture seule (embarquees au detail).
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable / Blamable.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('transport.carriers.view')",
|
||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/carriers/{carrierId}/contacts',
|
||||
uriVariables: [
|
||||
'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'),
|
||||
],
|
||||
// read:false : pas de stade lecture du parent. Le Link toProperty
|
||||
// resoudrait l'enfant (SELECT CarrierContact ... WHERE carrier = :id)
|
||||
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
||||
// manuellement par CarrierContactProcessor::linkParent (404 si absent).
|
||||
read: false,
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['carrier:write:contacts']],
|
||||
processor: CarrierContactProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['carrier:write:contacts']],
|
||||
processor: CarrierContactProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
processor: CarrierContactProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'carrier_contact')]
|
||||
#[ORM\Index(name: 'idx_carrier_contact_carrier', columns: ['carrier_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_contact_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_carrier_contact_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class CarrierContact implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'contacts')]
|
||||
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Carrier $carrier = null;
|
||||
|
||||
// RG-4.08 : aucun champ obligatoire isolement (≥ 1 champ rempli, garde
|
||||
// Processor + CHECK BDD). Les colonnes restent nullable au niveau ORM.
|
||||
#[ORM\Column(name: 'first_name', length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(name: 'last_name', length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(name: 'job_title', length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
|
||||
private ?string $jobTitle = null;
|
||||
|
||||
// Telephones en LECTURE seule : alimentes en ecriture via le tableau virtuel
|
||||
// `phones` (mappe par le CarrierContactProcessor). Pas de groupe write -> pas
|
||||
// de saisie directe (et donc exemptes du miroir Assert\Length, le Processor
|
||||
// borne deja la longueur).
|
||||
#[ORM\Column(name: 'phone_primary', length: 20, nullable: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $phonePrimary = null;
|
||||
|
||||
#[ORM\Column(name: 'phone_secondary', length: 20, nullable: true)]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?string $phoneSecondary = null;
|
||||
|
||||
#[ORM\Column(length: 180, nullable: true)]
|
||||
#[Assert\Email(message: 'L\'adresse email n\'est pas valide.')]
|
||||
#[Assert\Length(max: 180, maxMessage: 'L\'email ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
|
||||
private ?string $email = null;
|
||||
|
||||
/**
|
||||
* Telephones en ecriture (RG-4.08, max 2), NON persiste : le
|
||||
* CarrierContactProcessor normalise chaque numero (RG-4.13) puis le mappe vers
|
||||
* phonePrimary / phoneSecondary. null = non fourni (PATCH partiel : on ne
|
||||
* touche pas aux telephones existants). Un 3e numero -> 422 sur `phones`.
|
||||
*
|
||||
* @var null|list<string>
|
||||
*/
|
||||
#[Groups(['carrier:write:contacts'])]
|
||||
private ?array $phones = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCarrier(): ?Carrier
|
||||
{
|
||||
return $this->carrier;
|
||||
}
|
||||
|
||||
public function setCarrier(?Carrier $carrier): static
|
||||
{
|
||||
$this->carrier = $carrier;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFirstName(): ?string
|
||||
{
|
||||
return $this->firstName;
|
||||
}
|
||||
|
||||
public function setFirstName(?string $firstName): static
|
||||
{
|
||||
$this->firstName = $firstName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastName(): ?string
|
||||
{
|
||||
return $this->lastName;
|
||||
}
|
||||
|
||||
public function setLastName(?string $lastName): static
|
||||
{
|
||||
$this->lastName = $lastName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getJobTitle(): ?string
|
||||
{
|
||||
return $this->jobTitle;
|
||||
}
|
||||
|
||||
public function setJobTitle(?string $jobTitle): static
|
||||
{
|
||||
$this->jobTitle = $jobTitle;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhonePrimary(): ?string
|
||||
{
|
||||
return $this->phonePrimary;
|
||||
}
|
||||
|
||||
public function setPhonePrimary(?string $phonePrimary): static
|
||||
{
|
||||
$this->phonePrimary = $phonePrimary;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhoneSecondary(): ?string
|
||||
{
|
||||
return $this->phoneSecondary;
|
||||
}
|
||||
|
||||
public function setPhoneSecondary(?string $phoneSecondary): static
|
||||
{
|
||||
$this->phoneSecondary = $phoneSecondary;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function setEmail(?string $email): static
|
||||
{
|
||||
$this->email = $email;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|list<string>
|
||||
*/
|
||||
public function getPhones(): ?array
|
||||
{
|
||||
return $this->phones;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|list<string> $phones
|
||||
*/
|
||||
public function setPhones(?array $phones): static
|
||||
{
|
||||
$this->phones = $phones;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierPriceProcessor;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\ClientAddressInterface;
|
||||
use App\Shared\Domain\Contract\ClientInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SupplierAddressInterface;
|
||||
use App\Shared\Domain\Contract\SupplierInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Prix d'un transporteur (1:n) — onglet Prix (M4, RG-4.09→4.11). Une ligne porte
|
||||
* soit une branche CLIENT (client + adresse de livraison + site de depart), soit
|
||||
* une branche FOURNISSEUR (supplier + adresse d'appro + site de livraison),
|
||||
* selon `direction`. La coherence des branches est garantie en BDD par les CHECK
|
||||
* chk_carrier_price_client_branch / chk_carrier_price_supplier_branch.
|
||||
*
|
||||
* Relations cross-module (Client/Supplier/adresses M1-M2, Site Sites) referencees
|
||||
* via des contrats Shared (ClientInterface, SupplierInterface, ...) + resolve_target_entities
|
||||
* — JAMAIS d'import direct d'une entite d'un autre module (regle ABSOLUE n°1).
|
||||
* L'embed JSON au detail passe par les read-groups des entites concretes
|
||||
* (client:read / client_address:read / supplier:read / supplier_address:read /
|
||||
* site:read), inclus dans le contexte du Get racine de Carrier (§ 4.0).
|
||||
*
|
||||
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
|
||||
* transporteur). Ecriture : groupe `carrier:write:prices`.
|
||||
*
|
||||
* Sous-ressource API (ERP-161, spec § 4.5) — jumelle de CarrierAddress /
|
||||
* CarrierContact :
|
||||
* - POST /api/carriers/{carrierId}/prices : creation rattachee au transporteur
|
||||
* parent (Link toProperty 'carrier'), security transport.carriers.manage.
|
||||
* - PATCH / DELETE /api/carrier_prices/{id} : security transport.carriers.manage.
|
||||
* - GET /api/carrier_prices/{id} : lecture unitaire (security view).
|
||||
* Tout passe par le CarrierPriceProcessor (rattachement parent + RG-4.09→4.11 :
|
||||
* coherence de branche CLIENT/FOURNISSEUR + appartenance de l'adresse).
|
||||
*
|
||||
* Les champs communs (direction, containerType, pricingUnit, price, priceState)
|
||||
* sont obligatoires (Assert\NotBlank + Assert\Choice). L'obligation conditionnelle
|
||||
* des champs de branche (client/supplier + adresses + sites) et l'appartenance de
|
||||
* l'adresse au client/fournisseur sont portees par le Processor (violations Hydra
|
||||
* a la main) : ces RG dependent de relations resolues a la denormalisation et non
|
||||
* exprimables par une simple contrainte d'attribut.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('transport.carriers.view')",
|
||||
normalizationContext: ['groups' => [
|
||||
'carrier:item:read',
|
||||
'client:read', 'client_address:read',
|
||||
'supplier:read', 'supplier_address:read',
|
||||
'site:read', 'default:read',
|
||||
]],
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/carriers/{carrierId}/prices',
|
||||
uriVariables: [
|
||||
'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'),
|
||||
],
|
||||
// read:false : pas de stade lecture du parent. Le Link toProperty
|
||||
// resoudrait l'enfant (SELECT CarrierPrice ... WHERE carrier = :id) et
|
||||
// casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
||||
// manuellement par CarrierPriceProcessor::linkParent (404 si absent).
|
||||
read: false,
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => [
|
||||
'carrier:item:read',
|
||||
'client:read', 'client_address:read',
|
||||
'supplier:read', 'supplier_address:read',
|
||||
'site:read', 'default:read',
|
||||
]],
|
||||
denormalizationContext: ['groups' => ['carrier:write:prices']],
|
||||
processor: CarrierPriceProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => [
|
||||
'carrier:item:read',
|
||||
'client:read', 'client_address:read',
|
||||
'supplier:read', 'supplier_address:read',
|
||||
'site:read', 'default:read',
|
||||
]],
|
||||
denormalizationContext: ['groups' => ['carrier:write:prices']],
|
||||
processor: CarrierPriceProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
processor: CarrierPriceProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'carrier_price')]
|
||||
#[ORM\Index(name: 'idx_carrier_price_carrier', columns: ['carrier_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_price_client', columns: ['client_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_price_client_address', columns: ['client_delivery_address_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_price_departure_site', columns: ['departure_site_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_price_supplier', columns: ['supplier_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_price_supplier_address', columns: ['supplier_supply_address_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_price_delivery_site', columns: ['delivery_site_id'])]
|
||||
#[ORM\Index(name: 'idx_carrier_price_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_carrier_price_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class CarrierPrice implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'prices')]
|
||||
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Carrier $carrier = null;
|
||||
|
||||
/** CLIENT|FOURNISSEUR (RG-4.09) — pilote la branche active. */
|
||||
#[ORM\Column(length: 12)]
|
||||
#[Assert\NotBlank(message: 'Le sens du prix est obligatoire.')]
|
||||
#[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR'], message: 'Le sens du prix est invalide.')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
private ?string $direction = null;
|
||||
|
||||
// === Branche CLIENT (RG-4.10) ===
|
||||
// Obligation conditionnelle (direction=CLIENT) + appartenance de l'adresse au
|
||||
// client : portees par le CarrierPriceProcessor (relations resolues a la
|
||||
// denormalisation, hors portee d'une contrainte d'attribut).
|
||||
#[ORM\ManyToOne(targetEntity: ClientInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
private ?ClientInterface $client = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ClientAddressInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'client_delivery_address_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
private ?ClientAddressInterface $clientDeliveryAddress = null;
|
||||
|
||||
/** Adresse de depart = un des 3 sites (86/17/82). */
|
||||
#[ORM\ManyToOne(targetEntity: SiteInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'departure_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
private ?SiteInterface $departureSite = null;
|
||||
|
||||
// === Branche FOURNISSEUR (RG-4.11) ===
|
||||
#[ORM\ManyToOne(targetEntity: SupplierInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'supplier_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
private ?SupplierInterface $supplier = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: SupplierAddressInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'supplier_supply_address_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
private ?SupplierAddressInterface $supplierSupplyAddress = null;
|
||||
|
||||
/** Adresse de livraison = un des 3 sites (86/17/82). */
|
||||
#[ORM\ManyToOne(targetEntity: SiteInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'delivery_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
private ?SiteInterface $deliverySite = null;
|
||||
|
||||
// === Commun (toujours obligatoires, RG-4.10/4.11) ===
|
||||
/** BENNE|FOND_MOUVANT. */
|
||||
#[ORM\Column(name: 'container_type', length: 12)]
|
||||
#[Assert\NotBlank(message: 'Le type de contenant est obligatoire.')]
|
||||
#[Assert\Choice(choices: ['BENNE', 'FOND_MOUVANT'], message: 'Le type de contenant est invalide.')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
private ?string $containerType = null;
|
||||
|
||||
/** FORFAIT|TONNE. */
|
||||
#[ORM\Column(name: 'pricing_unit', length: 8)]
|
||||
#[Assert\NotBlank(message: 'L\'unite de tarification est obligatoire.')]
|
||||
#[Assert\Choice(choices: ['FORFAIT', 'TONNE'], message: 'L\'unite de tarification est invalide.')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
private ?string $pricingUnit = null;
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 12, scale: 2)]
|
||||
#[Assert\NotBlank(message: 'Le prix est obligatoire.')]
|
||||
#[Assert\PositiveOrZero(message: 'Le prix ne peut pas etre negatif.')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
private ?string $price = null;
|
||||
|
||||
/** EN_COURS|VALIDE|NON_VALIDE. */
|
||||
#[ORM\Column(name: 'price_state', length: 12)]
|
||||
#[Assert\NotBlank(message: 'L\'etat du prix est obligatoire.')]
|
||||
#[Assert\Choice(choices: ['EN_COURS', 'VALIDE', 'NON_VALIDE'], message: 'L\'etat du prix est invalide.')]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:prices'])]
|
||||
private ?string $priceState = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCarrier(): ?Carrier
|
||||
{
|
||||
return $this->carrier;
|
||||
}
|
||||
|
||||
public function setCarrier(?Carrier $carrier): static
|
||||
{
|
||||
$this->carrier = $carrier;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDirection(): ?string
|
||||
{
|
||||
return $this->direction;
|
||||
}
|
||||
|
||||
public function setDirection(?string $direction): static
|
||||
{
|
||||
$this->direction = $direction;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getClient(): ?ClientInterface
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function setClient(?ClientInterface $client): static
|
||||
{
|
||||
$this->client = $client;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getClientDeliveryAddress(): ?ClientAddressInterface
|
||||
{
|
||||
return $this->clientDeliveryAddress;
|
||||
}
|
||||
|
||||
public function setClientDeliveryAddress(?ClientAddressInterface $clientDeliveryAddress): static
|
||||
{
|
||||
$this->clientDeliveryAddress = $clientDeliveryAddress;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDepartureSite(): ?SiteInterface
|
||||
{
|
||||
return $this->departureSite;
|
||||
}
|
||||
|
||||
public function setDepartureSite(?SiteInterface $departureSite): static
|
||||
{
|
||||
$this->departureSite = $departureSite;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSupplier(): ?SupplierInterface
|
||||
{
|
||||
return $this->supplier;
|
||||
}
|
||||
|
||||
public function setSupplier(?SupplierInterface $supplier): static
|
||||
{
|
||||
$this->supplier = $supplier;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSupplierSupplyAddress(): ?SupplierAddressInterface
|
||||
{
|
||||
return $this->supplierSupplyAddress;
|
||||
}
|
||||
|
||||
public function setSupplierSupplyAddress(?SupplierAddressInterface $supplierSupplyAddress): static
|
||||
{
|
||||
$this->supplierSupplyAddress = $supplierSupplyAddress;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeliverySite(): ?SiteInterface
|
||||
{
|
||||
return $this->deliverySite;
|
||||
}
|
||||
|
||||
public function setDeliverySite(?SiteInterface $deliverySite): static
|
||||
{
|
||||
$this->deliverySite = $deliverySite;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContainerType(): ?string
|
||||
{
|
||||
return $this->containerType;
|
||||
}
|
||||
|
||||
public function setContainerType(?string $containerType): static
|
||||
{
|
||||
$this->containerType = $containerType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPricingUnit(): ?string
|
||||
{
|
||||
return $this->pricingUnit;
|
||||
}
|
||||
|
||||
public function setPricingUnit(?string $pricingUnit): static
|
||||
{
|
||||
$this->pricingUnit = $pricingUnit;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPrice(): ?string
|
||||
{
|
||||
return $this->price;
|
||||
}
|
||||
|
||||
public function setPrice(?string $price): static
|
||||
{
|
||||
$this->price = $price;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPriceState(): ?string
|
||||
{
|
||||
return $this->priceState;
|
||||
}
|
||||
|
||||
public function setPriceState(?string $priceState): static
|
||||
{
|
||||
$this->priceState = $priceState;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\QualimatCarrierSearchProvider;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
|
||||
/**
|
||||
* Mapping ORM LECTURE SEULE sur la table existante `qualimat_carrier`
|
||||
* (referentiel des transporteurs agrees QUALIMAT, ERP-39). La table est
|
||||
* alimentee/soft-deletee EXCLUSIVEMENT par la commande console `app:qualimat:sync` ;
|
||||
* cette entite n'expose donc AUCUNE ecriture (ni Post/Patch/Delete).
|
||||
*
|
||||
* Role M4 (ERP-155/157) :
|
||||
* - cible de la FK editable `carrier.qualimat_carrier_id` (§ 2.5) ;
|
||||
* - embarquee (groupe `qualimat:read`) dans la liste et le detail Carrier pour
|
||||
* afficher statut + date de validite QUALIMAT (RG-4.04) ;
|
||||
* - endpoint de recherche `GET /api/qualimat_carriers?search=` pour la saisie
|
||||
* assistee du nom (§ 4.7) — fuzzy name (+ siret), SEULEMENT les lignes actives,
|
||||
* tri name ASC, paginee ; logique portee par QualimatCarrierSearchProvider.
|
||||
*
|
||||
* La table reste hors `schema_filter` Doctrine (doctrine.yaml) : c'est la
|
||||
* migration modulaire Version20260612150000 qui possede son DDL et ses COMMENT
|
||||
* (pas l'ORM). Lecture seule + referentiel synchronise => exclue de
|
||||
* EntitiesAreTimestampableBlamableTest et non #[Auditable].
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
// Saisie assistee (§ 4.7 / RG-4.01) : ?search= fuzzy name (+ siret),
|
||||
// SEULEMENT les lignes actives, tri name ASC, paginee. La logique vit
|
||||
// dans le provider (forcage is_active + recherche multi-champs) car un
|
||||
// SearchFilter natif ne sait ni unifier name/siret sous un seul ?search=,
|
||||
// ni imposer cote serveur le filtre actif.
|
||||
new GetCollection(
|
||||
security: "is_granted('transport.carriers.view')",
|
||||
provider: QualimatCarrierSearchProvider::class,
|
||||
normalizationContext: ['groups' => ['qualimat:read', 'default:read']],
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('transport.carriers.view')",
|
||||
normalizationContext: ['groups' => ['qualimat:read', 'default:read']],
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
// Mapping reproduisant a l'identique le DDL de la migration ERP-39
|
||||
// (Version20260612150000) pour que `schema:update --force` reste un no-op :
|
||||
// contrainte d'unicite siret + index is_active.
|
||||
#[ORM\Table(name: 'qualimat_carrier')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_qualimat_carrier_siret', columns: ['siret'])]
|
||||
#[ORM\Index(name: 'idx_qualimat_carrier_active', columns: ['is_active'])]
|
||||
class QualimatCarrier
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
#[ORM\Column(type: 'bigint')]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?string $siret = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?string $address = null;
|
||||
|
||||
#[ORM\Column(name: 'postal_code', length: 10, nullable: true)]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?string $postalCode = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 32, nullable: true)]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?string $phone = null;
|
||||
|
||||
#[ORM\Column(length: 64, nullable: true)]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?string $department = null;
|
||||
|
||||
#[ORM\Column(length: 32)]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?string $status = null;
|
||||
|
||||
#[ORM\Column(name: 'validity_date', type: 'date_immutable', nullable: true)]
|
||||
#[Groups(['qualimat:read'])]
|
||||
private ?DateTimeImmutable $validityDate = null;
|
||||
|
||||
#[ORM\Column(name: 'is_active', options: ['default' => true])]
|
||||
#[Groups(['qualimat:read'])]
|
||||
#[SerializedName('isActive')]
|
||||
private bool $isActive = true;
|
||||
|
||||
// Colonne technique de synchro (soft-delete) — mappee pour completude, non
|
||||
// serialisee. Alimentee par app:qualimat:sync. columnDefinition pin la
|
||||
// precision TIMESTAMP(6) du DDL ERP-39 pour eviter un ALTER de schema:update
|
||||
// (le datetime_immutable par defaut mapperait sur TIMESTAMP(0)).
|
||||
#[ORM\Column(name: 'last_synced_at', type: 'datetime_immutable', columnDefinition: 'TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL')]
|
||||
private ?DateTimeImmutable $lastSyncedAt = null;
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getSiret(): ?string
|
||||
{
|
||||
return $this->siret;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getAddress(): ?string
|
||||
{
|
||||
return $this->address;
|
||||
}
|
||||
|
||||
public function getPostalCode(): ?string
|
||||
{
|
||||
return $this->postalCode;
|
||||
}
|
||||
|
||||
public function getCity(): ?string
|
||||
{
|
||||
return $this->city;
|
||||
}
|
||||
|
||||
public function getPhone(): ?string
|
||||
{
|
||||
return $this->phone;
|
||||
}
|
||||
|
||||
public function getDepartment(): ?string
|
||||
{
|
||||
return $this->department;
|
||||
}
|
||||
|
||||
public function getStatus(): ?string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function getValidityDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->validityDate;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->isActive;
|
||||
}
|
||||
|
||||
public function getLastSyncedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->lastSyncedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Repository;
|
||||
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
* Contrat du repository transporteurs (M4). Implementation Doctrine :
|
||||
* App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository.
|
||||
*/
|
||||
interface CarrierRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Carrier;
|
||||
|
||||
public function save(Carrier $carrier): void;
|
||||
|
||||
/**
|
||||
* QueryBuilder de SELECTION (filtres + tri) pour la liste. Exclut les
|
||||
* soft-deletes (deleted_at IS NOT NULL) et, par defaut, les archives.
|
||||
* Fetch-join uniquement qualimatCarrier (ManyToOne, sur — § 2.11) : la liste
|
||||
* n'embarque aucune sous-collection. Tri par defaut name ASC.
|
||||
*
|
||||
* Perimetre d'archivage (aligne sur ClientProvider/SupplierProvider/
|
||||
* ProviderProvider — toggle « Voir les archives » d'ERP-173) :
|
||||
* - $archivedOnly = true -> uniquement les archives (is_archived = true) ;
|
||||
* - sinon $includeArchived = true -> actifs + archives (echappatoire) ;
|
||||
* - par defaut -> actifs seuls (is_archived = false).
|
||||
* $archivedOnly a la priorite sur $includeArchived.
|
||||
*
|
||||
* @param list<string> $certificationTypes filtre repetable (OR) sur certificationType
|
||||
*/
|
||||
public function createListQueryBuilder(
|
||||
bool $includeArchived = false,
|
||||
?string $search = null,
|
||||
array $certificationTypes = [],
|
||||
bool $archivedOnly = false,
|
||||
): QueryBuilder;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Domain\Repository;
|
||||
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
* Contrat du repository du referentiel QUALIMAT (M4, lecture seule). Implementation
|
||||
* Doctrine : App\Module\Transport\Infrastructure\Doctrine\DoctrineQualimatCarrierRepository.
|
||||
*
|
||||
* La table `qualimat_carrier` est alimentee/soft-deletee EXCLUSIVEMENT par la
|
||||
* commande console `app:qualimat:sync` : ce contrat n'expose que de la lecture.
|
||||
*/
|
||||
interface QualimatCarrierRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* QueryBuilder de la saisie assistee (§ 4.7 / RG-4.01). Restreint aux lignes
|
||||
* actives (is_active = true), recherche fuzzy sur name (+ siret), tri name ASC.
|
||||
*
|
||||
* @param null|string $search texte de recherche libre (fuzzy name + siret)
|
||||
*/
|
||||
public function createSearchQueryBuilder(?string $search = null): QueryBuilder;
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Entity\CarrierAddress;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Adresse d'un transporteur (M4,
|
||||
* spec-back § 4.5). Jumeau du SupplierAddressProcessor (M2) / ProviderAddressProcessor
|
||||
* (M3), recentre sur le perimetre ERP-159, AVEC une garde propre au M4 : RG-4.05
|
||||
* (adresse obligatoire si le transporteur est affrete).
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au transporteur parent (linkParent) puis garde
|
||||
* RG-4.05 (guardCharteredAddress). RG-4.06 (code postal, Assert\Regex) est portee
|
||||
* par l'entite et jouee par API Platform AVANT ce processor.
|
||||
* - DELETE : aucune regle metier specifique (suppression physique directe).
|
||||
*
|
||||
* RG-4.05 vit ICI (et non en Assert\Callback sur l'entite) car elle depend du
|
||||
* transporteur PARENT, indisponible a la validation Symfony sur un POST
|
||||
* sous-ressource en read:false (le parent n'est rattache qu'au stade processor).
|
||||
* La violation est construite a la main avec le meme rendu Hydra que les
|
||||
* contraintes Symfony, donc consommable inline par champ (convention ERP-101).
|
||||
*
|
||||
* RG-4.07 (masquage du bouton « Valider » si QUALIMAT) est purement front : le
|
||||
* back accepte le PATCH normalement, aucune garde ici.
|
||||
*
|
||||
* La security d'operation (transport.carriers.manage) est appliquee par API
|
||||
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut.
|
||||
*
|
||||
* @implements ProcessorInterface<CarrierAddress, null|CarrierAddress>
|
||||
*/
|
||||
final class CarrierAddressProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof CarrierAddress) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->guardCharteredAddress($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache l'adresse au transporteur parent de la sous-ressource POST
|
||||
* (/carriers/{carrierId}/addresses) : la relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
|
||||
*/
|
||||
private function linkParent(CarrierAddress $address, array $uriVariables): void
|
||||
{
|
||||
if (null !== $address->getCarrier()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$carrierId = $uriVariables['carrierId'] ?? null;
|
||||
if (null === $carrierId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$carrier = $carrierId instanceof Carrier
|
||||
? $carrierId
|
||||
: $this->em->getRepository(Carrier::class)->find($carrierId);
|
||||
|
||||
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
|
||||
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
|
||||
// contrainte carrier_id NOT NULL).
|
||||
if (!$carrier instanceof Carrier) {
|
||||
throw new NotFoundHttpException('Transporteur introuvable.');
|
||||
}
|
||||
|
||||
$address->setCarrier($carrier);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.05 : si le transporteur parent est affrete (isChartered), l'adresse doit
|
||||
* porter Pays / Code postal / Ville / Adresse. Chaque champ manquant -> une
|
||||
* violation 422 sur son propre propertyPath (mapping inline ERP-101). La
|
||||
* validation porte sur l'ETAT RESULTANT de l'adresse (apres application du
|
||||
* payload), donc identique sur POST et sur PATCH partiel. Sans affretement,
|
||||
* l'adresse reste partielle (champs nullable, RG-4.06 inchangee).
|
||||
*/
|
||||
private function guardCharteredAddress(CarrierAddress $address): void
|
||||
{
|
||||
$carrier = $address->getCarrier();
|
||||
if (!$carrier instanceof Carrier || !$carrier->isChartered()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$required = [
|
||||
'country' => [$address->getCountry(), 'Le pays est obligatoire pour un transporteur affrété.'],
|
||||
'postalCode' => [$address->getPostalCode(), 'Le code postal est obligatoire pour un transporteur affrété.'],
|
||||
'city' => [$address->getCity(), 'La ville est obligatoire pour un transporteur affrété.'],
|
||||
'street' => [$address->getStreet(), 'L\'adresse est obligatoire pour un transporteur affrété.'],
|
||||
];
|
||||
|
||||
$violations = new ConstraintViolationList();
|
||||
foreach ($required as $path => [$value, $message]) {
|
||||
if (null === $value || '' === trim($value)) {
|
||||
$violations->add(new ConstraintViolation($message, null, [], $address, $path, $value));
|
||||
}
|
||||
}
|
||||
|
||||
if (0 < $violations->count()) {
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Entity\CarrierContact;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
use function count;
|
||||
use function is_string;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Contact d'un transporteur (M4,
|
||||
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
|
||||
* perimetre ERP-160, AVEC deux specificites M4 : RG-4.08 (≥ 1 champ rempli, max
|
||||
* 2 telephones) portee a la fois par le CHECK BDD chk_carrier_contact_filled et
|
||||
* par ce Processor.
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au transporteur parent (linkParent),
|
||||
* normalisation serveur RG-4.13 (prenom/nom Title Case, email lowercase),
|
||||
* mapping du tableau d'ecriture `phones` -> phonePrimary/phoneSecondary
|
||||
* (max 2, chiffres uniquement), puis garde RG-4.08 (≥ 1 champ) avant
|
||||
* persistance.
|
||||
* - DELETE : aucune regle metier specifique (suppression physique directe).
|
||||
*
|
||||
* RG-4.08 vit ICI (double du CHECK BDD) pour transformer une violation SQL (500
|
||||
* generique) en 422 propre rattachee au champ `firstName` (mapping inline
|
||||
* ERP-101). Le « max 2 telephones » est rattache au champ `phones` : seul
|
||||
* point de saisie des numeros (les colonnes phonePrimary/phoneSecondary sont en
|
||||
* lecture seule).
|
||||
*
|
||||
* La security d'operation (transport.carriers.manage) est appliquee par API
|
||||
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut
|
||||
* (Assert\Email, Assert\Length...).
|
||||
*
|
||||
* @implements ProcessorInterface<CarrierContact, null|CarrierContact>
|
||||
*/
|
||||
final class CarrierContactProcessor implements ProcessorInterface
|
||||
{
|
||||
/** RG-4.08 : nombre maximal de telephones par contact. */
|
||||
private const int MAX_PHONES = 2;
|
||||
|
||||
/** Longueur max d'un telephone normalise (colonne VARCHAR(20)). */
|
||||
private const int PHONE_MAX_LENGTH = 20;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly CarrierFieldNormalizer $normalizer,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof CarrierContact) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->normalize($data);
|
||||
$this->applyPhones($data);
|
||||
$this->validateAtLeastOneField($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache le contact au transporteur parent de la sous-ressource POST
|
||||
* (/carriers/{carrierId}/contacts) : la relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une ecriture. Sur PATCH (entite existante),
|
||||
* le transporteur est deja present -> no-op.
|
||||
*/
|
||||
private function linkParent(CarrierContact $contact, array $uriVariables): void
|
||||
{
|
||||
if (null !== $contact->getCarrier()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$carrierId = $uriVariables['carrierId'] ?? null;
|
||||
if (null === $carrierId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$carrier = $carrierId instanceof Carrier
|
||||
? $carrierId
|
||||
: $this->em->getRepository(Carrier::class)->find($carrierId);
|
||||
|
||||
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
|
||||
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
|
||||
// contrainte carrier_id NOT NULL).
|
||||
if (!$carrier instanceof Carrier) {
|
||||
throw new NotFoundHttpException('Transporteur introuvable.');
|
||||
}
|
||||
|
||||
$contact->setCarrier($carrier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisation serveur RG-4.13 des champs texte. Toutes les methodes du
|
||||
* normalizer sont null-safe : une chaine vide apres trim devient null (donc la
|
||||
* garde RG-4.08 detecte bien « champ non rempli »). Les telephones sont
|
||||
* traites a part (applyPhones).
|
||||
*/
|
||||
private function normalize(CarrierContact $contact): void
|
||||
{
|
||||
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
|
||||
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
|
||||
$contact->setJobTitle($this->blankToNull($contact->getJobTitle()));
|
||||
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappe le tableau d'ecriture `phones` (max 2, RG-4.08) vers phonePrimary /
|
||||
* phoneSecondary apres normalisation RG-4.13 (chiffres uniquement). Les
|
||||
* numeros vides (sans chiffre) sont ecartes. null = champ non fourni (PATCH
|
||||
* partiel) -> on ne touche pas aux telephones existants. Un 3e numero
|
||||
* exploitable, ou un numero trop long (> colonne VARCHAR(20)), -> 422 sur
|
||||
* `phones`.
|
||||
*/
|
||||
private function applyPhones(CarrierContact $contact): void
|
||||
{
|
||||
$phones = $contact->getPhones();
|
||||
if (null === $phones) {
|
||||
return;
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($phones as $phone) {
|
||||
$digits = $this->normalizer->normalizePhone(is_string($phone) ? $phone : null);
|
||||
if (null !== $digits) {
|
||||
$normalized[] = $digits;
|
||||
}
|
||||
}
|
||||
|
||||
$violations = new ConstraintViolationList();
|
||||
if (self::MAX_PHONES < count($normalized)) {
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Un contact ne peut comporter plus de deux téléphones.',
|
||||
null,
|
||||
[],
|
||||
$contact,
|
||||
'phones',
|
||||
$phones,
|
||||
));
|
||||
}
|
||||
foreach ($normalized as $digits) {
|
||||
if (self::PHONE_MAX_LENGTH < mb_strlen($digits)) {
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Un numéro de téléphone ne peut dépasser '.self::PHONE_MAX_LENGTH.' caractères.',
|
||||
null,
|
||||
[],
|
||||
$contact,
|
||||
'phones',
|
||||
$phones,
|
||||
));
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (0 < $violations->count()) {
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
|
||||
$contact->setPhonePrimary($normalized[0] ?? null);
|
||||
$contact->setPhoneSecondary($normalized[1] ?? null);
|
||||
// Nettoie le champ virtuel (non persiste, mais evite toute fuite ulterieure).
|
||||
$contact->setPhones(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.08 : un bloc Contact est valide des qu'au moins 1 champ est rempli
|
||||
* (firstName, lastName, jobTitle, phonePrimary ou email — meme perimetre que
|
||||
* le CHECK BDD chk_carrier_contact_filled, qui exclut phoneSecondary). Double
|
||||
* garde : leve une 422 propre rattachee a `firstName` plutot qu'une 500 SQL.
|
||||
* Joue apres normalisation + mapping telephones, donc les chaines vides sont
|
||||
* deja ramenees a null.
|
||||
*/
|
||||
private function validateAtLeastOneField(CarrierContact $contact): void
|
||||
{
|
||||
if (
|
||||
null === $contact->getFirstName()
|
||||
&& null === $contact->getLastName()
|
||||
&& null === $contact->getJobTitle()
|
||||
&& null === $contact->getPhonePrimary()
|
||||
&& null === $contact->getEmail()
|
||||
) {
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Renseignez au moins un champ pour le contact.',
|
||||
null,
|
||||
[],
|
||||
$contact,
|
||||
'firstName',
|
||||
null,
|
||||
));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim + chaine vide -> null (la fonction n'est pas normalisee en casse,
|
||||
* contrairement aux noms de personne). Garantit que RG-4.08 detecte un champ
|
||||
* « non rempli » meme si le client envoie une chaine vide.
|
||||
*/
|
||||
private function blankToNull(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : $value;
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Entity\CarrierPrice;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Prix d'un transporteur (M4,
|
||||
* spec-back § 4.5, ERP-161). Jumeau des CarrierAddressProcessor / CarrierContactProcessor.
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au transporteur parent (linkParent) puis
|
||||
* validation de la coherence de branche CLIENT/FOURNISSEUR (RG-4.09→4.11).
|
||||
* - DELETE : suppression physique directe (aucune regle metier specifique).
|
||||
*
|
||||
* RG-4.10 (branche CLIENT) : `client`, `clientDeliveryAddress`, `departureSite`
|
||||
* obligatoires ; l'adresse de livraison doit appartenir au client choisi.
|
||||
* RG-4.11 (branche FOURNISSEUR) : `supplier`, `supplierSupplyAddress`,
|
||||
* `deliverySite` obligatoires ; l'adresse d'appro doit appartenir au fournisseur.
|
||||
* Ces RG vivent ICI (et non en contrainte d'attribut) car elles dependent de
|
||||
* relations resolues a la denormalisation (et le parent carrier est indisponible
|
||||
* en validation Symfony sur un POST sous-ressource read:false). On nettoie aussi
|
||||
* la branche opposee (les CHECK BDD imposent ses colonnes nulles) — transforme une
|
||||
* violation SQL (500) en 422 propre rattachee au champ (mapping inline ERP-101).
|
||||
*
|
||||
* Les champs communs obligatoires (direction, containerType, pricingUnit, price,
|
||||
* priceState) sont valides en amont par les contraintes d'attribut (Assert\NotBlank
|
||||
* + Assert\Choice), de meme que la security d'operation (transport.carriers.manage).
|
||||
*
|
||||
* @implements ProcessorInterface<CarrierPrice, null|CarrierPrice>
|
||||
*/
|
||||
final class CarrierPriceProcessor implements ProcessorInterface
|
||||
{
|
||||
private const string DIRECTION_CLIENT = 'CLIENT';
|
||||
|
||||
private const string DIRECTION_SUPPLIER = 'FOURNISSEUR';
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof CarrierPrice) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->validateBranch($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache le prix au transporteur parent de la sous-ressource POST
|
||||
* (/carriers/{carrierId}/prices) : la relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une ecriture. Sur PATCH (entite existante),
|
||||
* le transporteur est deja present -> no-op.
|
||||
*/
|
||||
private function linkParent(CarrierPrice $price, array $uriVariables): void
|
||||
{
|
||||
if (null !== $price->getCarrier()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$carrierId = $uriVariables['carrierId'] ?? null;
|
||||
if (null === $carrierId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$carrier = $carrierId instanceof Carrier
|
||||
? $carrierId
|
||||
: $this->em->getRepository(Carrier::class)->find($carrierId);
|
||||
|
||||
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
|
||||
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
|
||||
// contrainte carrier_id NOT NULL).
|
||||
if (!$carrier instanceof Carrier) {
|
||||
throw new NotFoundHttpException('Transporteur introuvable.');
|
||||
}
|
||||
|
||||
$price->setCarrier($carrier);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.09→4.11 : valide la coherence de la branche active (CLIENT vs
|
||||
* FOURNISSEUR) et nettoie la branche opposee (les CHECK BDD imposent ses
|
||||
* colonnes nulles). Toutes les violations sont collectees puis renvoyees d'un
|
||||
* coup (un seul aller-retour, mapping inline par champ — ERP-101). La direction
|
||||
* elle-meme est deja garantie CLIENT|FOURNISSEUR par Assert\NotBlank + Choice.
|
||||
*/
|
||||
private function validateBranch(CarrierPrice $price): void
|
||||
{
|
||||
$violations = new ConstraintViolationList();
|
||||
|
||||
if (self::DIRECTION_CLIENT === $price->getDirection()) {
|
||||
$this->requireField($violations, $price, 'client', $price->getClient(), 'Le client est obligatoire pour un prix client.');
|
||||
$this->requireField($violations, $price, 'clientDeliveryAddress', $price->getClientDeliveryAddress(), 'L\'adresse de livraison du client est obligatoire pour un prix client.');
|
||||
$this->requireField($violations, $price, 'departureSite', $price->getDepartureSite(), 'Le site de depart est obligatoire pour un prix client.');
|
||||
|
||||
// RG-4.10 : l'adresse de livraison doit appartenir au client choisi.
|
||||
$client = $price->getClient();
|
||||
$address = $price->getClientDeliveryAddress();
|
||||
if (null !== $client && null !== $address && $address->getClient()?->getId() !== $client->getId()) {
|
||||
$violations->add($this->violation($price, 'clientDeliveryAddress', 'L\'adresse de livraison doit appartenir au client selectionne.'));
|
||||
}
|
||||
|
||||
// Coherence CHECK chk_carrier_price_client_branch : branche fournisseur nulle.
|
||||
$price->setSupplier(null);
|
||||
$price->setSupplierSupplyAddress(null);
|
||||
$price->setDeliverySite(null);
|
||||
} elseif (self::DIRECTION_SUPPLIER === $price->getDirection()) {
|
||||
$this->requireField($violations, $price, 'supplier', $price->getSupplier(), 'Le fournisseur est obligatoire pour un prix fournisseur.');
|
||||
$this->requireField($violations, $price, 'supplierSupplyAddress', $price->getSupplierSupplyAddress(), 'L\'adresse d\'approvisionnement est obligatoire pour un prix fournisseur.');
|
||||
$this->requireField($violations, $price, 'deliverySite', $price->getDeliverySite(), 'Le site de livraison est obligatoire pour un prix fournisseur.');
|
||||
|
||||
// RG-4.11 : l'adresse d'appro doit appartenir au fournisseur choisi.
|
||||
$supplier = $price->getSupplier();
|
||||
$address = $price->getSupplierSupplyAddress();
|
||||
if (null !== $supplier && null !== $address && $address->getSupplier()?->getId() !== $supplier->getId()) {
|
||||
$violations->add($this->violation($price, 'supplierSupplyAddress', 'L\'adresse d\'approvisionnement doit appartenir au fournisseur selectionne.'));
|
||||
}
|
||||
|
||||
// Coherence CHECK chk_carrier_price_supplier_branch : branche client nulle.
|
||||
$price->setClient(null);
|
||||
$price->setClientDeliveryAddress(null);
|
||||
$price->setDepartureSite(null);
|
||||
}
|
||||
|
||||
if (0 < $violations->count()) {
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une violation « champ obligatoire » sur `$path` si la relation est
|
||||
* absente (branche active, RG-4.10/4.11).
|
||||
*/
|
||||
private function requireField(ConstraintViolationList $violations, CarrierPrice $price, string $path, ?object $value, string $message): void
|
||||
{
|
||||
if (null === $value) {
|
||||
$violations->add($this->violation($price, $path, $message));
|
||||
}
|
||||
}
|
||||
|
||||
private function violation(CarrierPrice $price, string $path, string $message): ConstraintViolation
|
||||
{
|
||||
return new ConstraintViolation($message, null, [], $price, $path, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use JsonException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture du repertoire transporteurs (M4, formulaire principal). Cf.
|
||||
* spec-back M4 § 4.3 / § 4.4 + RG-4.12 / RG-4.13 / RG-4.14. Jumeau du
|
||||
* SupplierProcessor (M2), recentre sur le perimetre WT4 (formulaire principal :
|
||||
* normalisation, gating archive, 409 doublon de nom).
|
||||
*
|
||||
* Sequence (POST / PATCH) :
|
||||
* 1. Gating archive (mode strict RG-4.14). La security d'operation laisse entrer
|
||||
* `transport.carriers.manage` (Admin + Bureau) ; ce processor re-gate
|
||||
* finement : un payload basculant `isArchived` exige `transport.carriers.archive`
|
||||
* (Admin seul) -> 403, et une requete d'archivage ne peut modifier aucun autre
|
||||
* champ -> 422. C'est ce qui empeche Bureau d'archiver (manage sans archive).
|
||||
* 2. Normalisation serveur (RG-4.13) via CarrierFieldNormalizer (name UPPER,
|
||||
* liotPlates « ; »-normalise). Les champs personne / telephone / email sont
|
||||
* portes par les sous-ressources Contact (WT7), pas par le formulaire principal.
|
||||
* 3. Pose / retrait de archivedAt (RG-4.14 true=now, false=null).
|
||||
* 4. Persistance via le persist_processor Doctrine, avec traduction des
|
||||
* collisions d'unicite partielle (uq_carrier_name_active) en 409 (RG-4.12
|
||||
* doublon de nom ; conflit de restauration).
|
||||
*
|
||||
* Les RG conditionnelles du formulaire principal (RG-4.01 certification obligatoire
|
||||
* sauf LIOT, RG-4.02 AUTRE -> decharge, RG-4.03 affrete -> indexation/benne/volume)
|
||||
* sont portees par un Assert\Callback + ->atPath() sur l'entite Carrier (joue par
|
||||
* API Platform AVANT ce processor), pour que chaque 422 porte un propertyPath
|
||||
* consommable par useFormErrors (mapping inline, pas un toast — convention ERP-101).
|
||||
*
|
||||
* @implements ProcessorInterface<Carrier, Carrier>
|
||||
*/
|
||||
final class CarrierProcessor implements ProcessorInterface
|
||||
{
|
||||
/** Champs ecrivables du formulaire principal (groupe carrier:write:main). */
|
||||
private const array MAIN_FIELDS = [
|
||||
'name', 'qualimatCarrier', 'certificationType', 'isChartered',
|
||||
'indexationRate', 'containerType', 'volumeM3', 'dischargeDocument', 'liotPlates',
|
||||
];
|
||||
|
||||
/** Champ d'archivage (groupe carrier:write:archive). */
|
||||
private const string ARCHIVE_FIELD = 'isArchived';
|
||||
|
||||
private const string PERM_ARCHIVE = 'transport.carriers.archive';
|
||||
|
||||
/**
|
||||
* Memoisation du dernier corps de requete decode, clos par le contenu brut.
|
||||
* payloadKeys() est appele plusieurs fois par requete : on evite de rejouer
|
||||
* json_decode. La cle etant le contenu lui-meme et le calcul une fonction pure
|
||||
* de ce contenu, aucune fuite n'est possible entre requetes sur ce service
|
||||
* partage (un meme corps redonne les memes cles).
|
||||
*/
|
||||
private ?string $decodedContent = null;
|
||||
|
||||
/** @var list<string> Cles de premier niveau correspondant au corps memoise. */
|
||||
private array $decodedPayloadKeys = [];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly CarrierFieldNormalizer $normalizer,
|
||||
private readonly Security $security,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof Carrier) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
// Reinitialisation de la memoisation du payload en debut de traitement :
|
||||
// le service est partage (stateful), on repart du corps de LA requete
|
||||
// courante et on n'herite jamais des cles decodees d'une requete passee.
|
||||
$this->decodedContent = null;
|
||||
$this->decodedPayloadKeys = [];
|
||||
|
||||
$isArchiveRequest = $this->guardArchive($data, $this->writablePayloadKeys());
|
||||
|
||||
$this->normalize($data);
|
||||
|
||||
try {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
// Le seul index unique partiel est uq_carrier_name_active
|
||||
// (LOWER(name) parmi non-archives/non-deletes — § 2.6).
|
||||
if ($isArchiveRequest && false === $data->isArchived()) {
|
||||
// RG-4.14 : restauration en conflit avec un homonyme actif.
|
||||
throw new ConflictHttpException(
|
||||
'Restauration impossible : un autre transporteur a pris le nom entre-temps.',
|
||||
$e,
|
||||
);
|
||||
}
|
||||
|
||||
// RG-4.12 : doublon de nom de transporteur.
|
||||
throw new ConflictHttpException(
|
||||
sprintf('Un transporteur nommé "%s" existe déjà.', (string) $data->getName()),
|
||||
$e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.14 : si le payload bascule reellement isArchived, exige la permission
|
||||
* archive (403), interdit toute autre modification (422) et pose/retire
|
||||
* archivedAt. Retourne true si la requete est une requete d'archivage.
|
||||
*
|
||||
* Le gating est restreint a la mise a jour d'un transporteur existant ET au
|
||||
* seul cas ou isArchived change vraiment : un POST (entite non encore geree
|
||||
* par l'ORM) ou un PATCH « representation complete » renvoyant isArchived
|
||||
* inchange ne doit declencher ni 403 ni 422 parasite.
|
||||
*
|
||||
* @param list<string> $writableKeys cles ecrivables du payload (hors @* et champs inconnus)
|
||||
*/
|
||||
private function guardArchive(Carrier $data, array $writableKeys): bool
|
||||
{
|
||||
// POST / entite non geree : l'archivage est une action de mise a jour.
|
||||
if (!$this->em->contains($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// isArchived inchange par rapport a l'etat persiste : pas une requete
|
||||
// d'archivage (cas du PATCH representation complete).
|
||||
if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted(self::PERM_ARCHIVE)) {
|
||||
throw new AccessDeniedHttpException(sprintf(
|
||||
'Le champ "%s" requiert la permission "%s".',
|
||||
self::ARCHIVE_FIELD,
|
||||
self::PERM_ARCHIVE,
|
||||
));
|
||||
}
|
||||
|
||||
// RG-4.14 : une requete d'archivage ne modifie aucun autre champ ecrivable.
|
||||
if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) {
|
||||
throw new UnprocessableEntityHttpException(
|
||||
'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".',
|
||||
);
|
||||
}
|
||||
|
||||
// RG-4.14 (true -> now) / (false -> null).
|
||||
$data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisation serveur du formulaire principal (RG-4.13). name (non-nullable)
|
||||
* et liotPlates (cas LIOT) sont les seuls champs texte du formulaire principal ;
|
||||
* le contact (personne / telephone / email) est normalise par son propre
|
||||
* processor (sous-ressource, WT7). Les setters ne sont touches que si une valeur
|
||||
* est presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel.
|
||||
*/
|
||||
private function normalize(Carrier $data): void
|
||||
{
|
||||
if (null !== $data->getName()) {
|
||||
$data->setName((string) $this->normalizer->normalizeName($data->getName()));
|
||||
}
|
||||
|
||||
if (null !== $data->getLiotPlates()) {
|
||||
$data->setLiotPlates($this->normalizer->normalizeLiotPlates($data->getLiotPlates()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cles ecrivables effectivement presentes dans le payload : on retire les cles
|
||||
* JSON-LD (@id, @context...) et tout champ non rattache a un groupe d'ecriture
|
||||
* connu. C'est la base du 422 d'archivage (RG-4.14) — sans elles, un PATCH
|
||||
* « representation complete » porteur de @id ferait croire a une modification
|
||||
* multi-champs.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function writablePayloadKeys(): array
|
||||
{
|
||||
$writable = array_merge(self::MAIN_FIELDS, [self::ARCHIVE_FIELD]);
|
||||
|
||||
return array_values(array_intersect($this->payloadKeys(), $writable));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une
|
||||
* entite non geree (creation/POST), l'etat persiste est vide : toute valeur
|
||||
* non-null est alors un changement.
|
||||
*/
|
||||
private function fieldChanged(Carrier $data, string $field, mixed $newValue): bool
|
||||
{
|
||||
$original = $this->originalData($data);
|
||||
|
||||
return $newValue !== ($original[$field] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot des valeurs persistees de l'entite (telles que chargees, avant
|
||||
* application du payload). Vide pour une entite non geree (POST).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function originalData(Carrier $data): array
|
||||
{
|
||||
if (!$this->em->contains($data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->em->getUnitOfWork()->getOriginalEntityData($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cles de premier niveau effectivement envoyees par le client (payload JSON
|
||||
* brut), filtrage compris. Pour un PATCH merge-patch+json, ce sont les seuls
|
||||
* champs modifies.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function payloadKeys(): array
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
if (null === $request) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = $request->getContent();
|
||||
|
||||
// Cache hit : meme corps brut que le dernier decodage -> memes cles.
|
||||
if ($content === $this->decodedContent) {
|
||||
return $this->decodedPayloadKeys;
|
||||
}
|
||||
|
||||
$this->decodedContent = $content;
|
||||
$this->decodedPayloadKeys = $this->extractPayloadKeys($content);
|
||||
|
||||
return $this->decodedPayloadKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode le corps brut et en extrait les cles de premier niveau (chaines).
|
||||
* Corps vide ou JSON invalide -> aucune cle.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractPayloadKeys(string $content): array
|
||||
{
|
||||
if ('' === $content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Repository\CarrierRepositoryInterface;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider du repertoire transporteurs (M4, spec-back § 4.1 / § 4.2). Jumeau du
|
||||
* SupplierProvider (M2), simplifie : pas de cloisonnement par site (§ 2.3) et
|
||||
* aucune sous-collection a hydrater en liste (le contrat liste n'embarque que
|
||||
* qualimatCarrier, deja fetch-joine par le repository — § 2.11).
|
||||
*
|
||||
* Collection (GET /api/carriers) :
|
||||
* - exclut par defaut les archives (is_archived = true) ET les soft-deletes ;
|
||||
* - ?includeArchived=true reintegre les archives (soft-deletes toujours exclus) ;
|
||||
* - ?archivedOnly=true n'expose QUE les archives (prioritaire sur includeArchived,
|
||||
* aligne sur Client/Supplier/Provider — toggle « Voir les archives » ERP-173) ;
|
||||
* - filtres ?search= (fuzzy name) et ?certificationType= (repetable) ;
|
||||
* - tri par defaut name ASC ; pagination Hydra (regle n°13) + echappatoire
|
||||
* ?pagination=false.
|
||||
*
|
||||
* Item (GET /api/carriers/{id}) : 404 si introuvable OU soft-delete. Les archives
|
||||
* restent consultables en detail.
|
||||
*
|
||||
* @implements ProviderInterface<Carrier>
|
||||
*/
|
||||
final class CarrierProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository')]
|
||||
private readonly CarrierRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Carrier|iterable|Paginator|null
|
||||
{
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
return $this->provideCollection($operation, $context);
|
||||
}
|
||||
|
||||
return $this->provideItem($uriVariables);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*
|
||||
* @return list<Carrier>|Paginator<Carrier>
|
||||
*/
|
||||
private function provideCollection(Operation $operation, array $context): array|Paginator
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
||||
$archivedOnly = $this->readBool($filters['archivedOnly'] ?? false);
|
||||
$search = $filters['search'] ?? null;
|
||||
$certificationTypes = $this->readStringList($filters['certificationType'] ?? []);
|
||||
|
||||
$qb = $this->repository->createListQueryBuilder(
|
||||
$includeArchived,
|
||||
is_string($search) ? $search : null,
|
||||
$certificationTypes,
|
||||
$archivedOnly,
|
||||
);
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete (selects front).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
// @var list<Carrier> $carriers
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
// fetchJoinCollection: false — la seule jointure est un ManyToOne (sur),
|
||||
// pas une to-many : pas de besoin du mode collection du Paginator.
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $uriVariables
|
||||
*/
|
||||
private function provideItem(array $uriVariables): ?Carrier
|
||||
{
|
||||
$id = $uriVariables['id'] ?? null;
|
||||
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$carrier = $this->repository->findById((int) $id);
|
||||
if (null === $carrier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Soft-delete : jamais expose (404). Les archives restent consultables.
|
||||
if (null !== $carrier->getDeletedAt()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $carrier;
|
||||
}
|
||||
|
||||
private function readBool(mixed $raw): bool
|
||||
{
|
||||
if (is_bool($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste de chaines (valeur unique ou ?key[]=a&key[]=b).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function readStringList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_string($value) && '' !== trim($value)) {
|
||||
$out[] = trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Transport\Domain\Entity\QualimatCarrier;
|
||||
use App\Module\Transport\Domain\Repository\QualimatCarrierRepositoryInterface;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider de la saisie assistee QUALIMAT (spec-back § 4.7 / RG-4.01).
|
||||
*
|
||||
* GET /api/qualimat_carriers?search=<texte> :
|
||||
* - restreint aux lignes actives (is_active = true) — regle serveur, pas un
|
||||
* filtre client desactivable ;
|
||||
* - recherche fuzzy insensible a la casse sur name (+ siret) ;
|
||||
* - tri par name ASC ;
|
||||
* - pagination Hydra (regle n°13) + echappatoire ?pagination=false (selects).
|
||||
*
|
||||
* Branche uniquement sur la GetCollection ; le Get unitaire reste servi par le
|
||||
* provider ORM par defaut (lecture seule, aucune ecriture exposee).
|
||||
*
|
||||
* @implements ProviderInterface<QualimatCarrier>
|
||||
*/
|
||||
final class QualimatCarrierSearchProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineQualimatCarrierRepository')]
|
||||
private readonly QualimatCarrierRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<QualimatCarrier>|Paginator<QualimatCarrier>
|
||||
*/
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|Paginator
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
$search = $filters['search'] ?? null;
|
||||
|
||||
$qb = $this->repository->createSearchQueryBuilder(is_string($search) ? $search : null);
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete (selects front).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
// @var list<QualimatCarrier> $carriers
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
// fetchJoinCollection: false — aucune jointure to-many (referentiel plat).
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
|
||||
}
|
||||
}
|
||||
@@ -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,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Repository\CarrierRepositoryInterface;
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Export XLSX du repertoire transporteurs (M4, spec-back § 4.6). Jumeau des
|
||||
* controllers d'export SupplierExportController (M2) / ProviderExportController
|
||||
* (M3) — references en prose volontairement (pas de {@see} : un import
|
||||
* inter-module violerait la regle ABSOLUE n°1). Simplifie : pas de cloisonnement
|
||||
* par site (§ 2.3) ni de colonne gatee par une permission comptable.
|
||||
*
|
||||
* Controller Symfony custom (et non operation API Platform) car il produit un
|
||||
* binaire de fichier, pas une representation Hydra. `priority: 1` est OBLIGATOIRE
|
||||
* sur la route : sans cela API Platform capterait `/api/carriers/export.xlsx`
|
||||
* comme l'item `GET /api/carriers/{id}.{_format}` (id="export", _format="xlsx")
|
||||
* — cf. CLAUDE.md « controller custom sous /api ».
|
||||
*
|
||||
* Separation des responsabilites :
|
||||
* - le COMMENT (generation du fichier) est delegue au service Shared
|
||||
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
|
||||
* - le QUOI vit ICI : selection des transporteurs (MEMES filtres que
|
||||
* `GET /api/carriers`, via {@see CarrierRepositoryInterface::createListQueryBuilder()}
|
||||
* — l'export reflete exactement ce que l'utilisateur voit a l'ecran) et mapping
|
||||
* metier des colonnes.
|
||||
*/
|
||||
#[AsController]
|
||||
final class CarrierExportController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository')]
|
||||
private readonly CarrierRepositoryInterface $repository,
|
||||
private readonly SpreadsheetExporterInterface $exporter,
|
||||
) {}
|
||||
|
||||
#[Route('/api/carriers/export.xlsx', name: 'transport_carriers_export_xlsx', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('transport.carriers.view')]
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
// Memes filtres que la vue liste (CarrierProvider) pour que l'export
|
||||
// reflete exactement ce que l'utilisateur voit a l'ecran :
|
||||
// - includeArchived : reintegre les archives en plus des actifs ;
|
||||
// - archivedOnly : n'exporte QUE les archives (prioritaire sur
|
||||
// includeArchived, aligne sur le provider — toggle « Voir les archives ») ;
|
||||
// - search : recherche fuzzy sur le nom ;
|
||||
// - certificationType : filtre repetable (?certificationType[]=A&...).
|
||||
$includeArchived = $this->readBool($request->query->get('includeArchived'));
|
||||
$archivedOnly = $this->readBool($request->query->get('archivedOnly'));
|
||||
$search = $request->query->getString('search') ?: null;
|
||||
$certificationTypes = $this->readStringList($request->query->all()['certificationType'] ?? []);
|
||||
|
||||
/** @var list<Carrier> $carriers */
|
||||
$carriers = $this->repository
|
||||
->createListQueryBuilder($includeArchived, $search, $certificationTypes, $archivedOnly)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
'Répertoire transporteurs',
|
||||
$this->buildHeaders(),
|
||||
$this->buildRows($carriers),
|
||||
);
|
||||
|
||||
return $this->buildResponse($binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes de l'export (spec § 4.6).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Nom',
|
||||
'Certification',
|
||||
'Statut QUALIMAT',
|
||||
'Date de validité',
|
||||
'Affrété',
|
||||
'Volume m³',
|
||||
'Date de création',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Carrier> $carriers
|
||||
*
|
||||
* @return iterable<list<null|scalar>>
|
||||
*/
|
||||
private function buildRows(array $carriers): iterable
|
||||
{
|
||||
foreach ($carriers as $carrier) {
|
||||
// Statut / date de validite proviennent du referentiel QUALIMAT lie
|
||||
// (RG-4.04), deja fetch-joine par le repository (anti N+1, § 2.11).
|
||||
$qualimat = $carrier->getQualimatCarrier();
|
||||
|
||||
yield [
|
||||
$carrier->getName(),
|
||||
$carrier->getCertificationType() ?? '',
|
||||
$qualimat?->getStatus() ?? '',
|
||||
$qualimat?->getValidityDate()?->format('d/m/Y') ?? '',
|
||||
$carrier->isChartered() ? 'Oui' : 'Non',
|
||||
$carrier->getVolumeM3() ?? '',
|
||||
$carrier->getCreatedAt()?->format('d/m/Y'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function buildResponse(string $binary): Response
|
||||
{
|
||||
$filename = sprintf('repertoire-transporteurs-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
|
||||
|
||||
$response = new Response($binary);
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||
* Aligne sur CarrierProvider pour un comportement identique a la liste.
|
||||
*/
|
||||
private function readBool(mixed $raw): bool
|
||||
{
|
||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste de chaines (valeur unique ou liste).
|
||||
* Aligne sur CarrierProvider pour un comportement identique a la liste.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function readStringList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_string($value) && '' !== trim($value)) {
|
||||
$out[] = trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Entity\CarrierPrice;
|
||||
use App\Module\Transport\Domain\Repository\CarrierRepositoryInterface;
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Export XLSX du tableau Prix d'un transporteur (M4, spec-back § 4.6 / spec-front
|
||||
* § « Onglet Prix »). Reproduit le tableau de consultation regroupe par type de
|
||||
* contenant (Fond Mouvant / Benne — colonnes du docx p.10).
|
||||
*
|
||||
* Controller Symfony custom (binaire de fichier, pas une representation Hydra).
|
||||
* `priority: 1` est OBLIGATOIRE : sans cela API Platform capterait
|
||||
* `/api/carriers/{id}/prices/export.xlsx` via ses routes generiques.
|
||||
*
|
||||
* Separation des responsabilites : le COMMENT (generation) est delegue au service
|
||||
* Shared {@see SpreadsheetExporterInterface} ; le QUOI (chargement du transporteur,
|
||||
* regroupement par contenant, mapping metier des colonnes) vit ICI.
|
||||
*
|
||||
* Adresses cross-module : les contrats Shared (ClientInterface / SupplierInterface
|
||||
* / SiteInterface) exposent volontairement le minimum (regle ABSOLUE n°1). Faute
|
||||
* d'acceder au detail postal d'une adresse Client/Fournisseur sans coupler au
|
||||
* module Commercial, les colonnes d'adresse identifient le point par le libelle
|
||||
* disponible : nom du site pour un Site, raison sociale du client/fournisseur pour
|
||||
* une adresse de livraison/approvisionnement.
|
||||
*/
|
||||
#[AsController]
|
||||
final class CarrierPriceExportController
|
||||
{
|
||||
/** Libelles d'affichage des enums (spec-front « Onglet Prix »). */
|
||||
private const array CONTAINER_LABELS = ['BENNE' => 'Benne', 'FOND_MOUVANT' => 'Fond Mouvant'];
|
||||
|
||||
private const array PRICE_STATE_LABELS = [
|
||||
'EN_COURS' => 'En cours',
|
||||
'VALIDE' => 'Validé',
|
||||
'NON_VALIDE' => 'Non validé',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineCarrierRepository')]
|
||||
private readonly CarrierRepositoryInterface $repository,
|
||||
private readonly SpreadsheetExporterInterface $exporter,
|
||||
) {}
|
||||
|
||||
#[Route('/api/carriers/{id}/prices/export.xlsx', name: 'transport_carrier_prices_export_xlsx', requirements: ['id' => '\d+'], methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('transport.carriers.view')]
|
||||
public function __invoke(int $id): Response
|
||||
{
|
||||
$carrier = $this->repository->findById($id);
|
||||
// Soft-delete jamais expose (comme CarrierProvider::provideItem) : 404.
|
||||
if (null === $carrier || null !== $carrier->getDeletedAt()) {
|
||||
throw new NotFoundHttpException('Transporteur introuvable.');
|
||||
}
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
'Prix transporteur',
|
||||
$this->buildHeaders(),
|
||||
$this->buildRows($carrier),
|
||||
);
|
||||
|
||||
return $this->buildResponse($carrier, $binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes du tableau Prix regroupe (spec-front « Onglet Prix » / docx p.10).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Type de contenant',
|
||||
'Transporteurs',
|
||||
'Adresse APRO ou Adresse Sites',
|
||||
'Adresse livraisons',
|
||||
'Forfait €',
|
||||
'Tonne €',
|
||||
'Indexation',
|
||||
'État du prix',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lignes regroupees par type de contenant (Fond Mouvant / Benne). On trie les
|
||||
* prix par contenant puis position pour materialiser le regroupement.
|
||||
*
|
||||
* @return iterable<list<null|scalar>>
|
||||
*/
|
||||
private function buildRows(Carrier $carrier): iterable
|
||||
{
|
||||
$prices = $carrier->getPrices()->toArray();
|
||||
usort(
|
||||
$prices,
|
||||
static fn (CarrierPrice $a, CarrierPrice $b): int => [$a->getContainerType(), $a->getPosition()]
|
||||
<=> [$b->getContainerType(), $b->getPosition()],
|
||||
);
|
||||
|
||||
// Indexation : portee par le transporteur (RG-4.03), identique pour toutes
|
||||
// ses lignes de prix. Vide si non renseigne (spec-front).
|
||||
$indexation = $carrier->getIndexationRate() ?? '';
|
||||
|
||||
foreach ($prices as $price) {
|
||||
$isForfait = 'FORFAIT' === $price->getPricingUnit();
|
||||
|
||||
yield [
|
||||
self::CONTAINER_LABELS[$price->getContainerType()] ?? $price->getContainerType(),
|
||||
$carrier->getName(),
|
||||
$this->formatDeparture($price),
|
||||
$this->formatDelivery($price),
|
||||
$isForfait ? $price->getPrice() : '',
|
||||
$isForfait ? '' : $price->getPrice(),
|
||||
$indexation,
|
||||
self::PRICE_STATE_LABELS[$price->getPriceState()] ?? $price->getPriceState(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Point de depart du prix (colonne « Adresse APRO ou Adresse Sites ») :
|
||||
* - branche CLIENT : le site de depart (un des 3 sites 86/17/82) ;
|
||||
* - branche FOURNISSEUR : l'adresse d'approvisionnement, identifiee par la
|
||||
* raison sociale du fournisseur (cf. note de classe sur les contrats Shared).
|
||||
*/
|
||||
private function formatDeparture(CarrierPrice $price): string
|
||||
{
|
||||
if ('CLIENT' === $price->getDirection()) {
|
||||
return $price->getDepartureSite()?->getName() ?? '';
|
||||
}
|
||||
|
||||
return $price->getSupplierSupplyAddress()?->getSupplier()?->getCompanyName() ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Point de livraison du prix (colonne « Adresse livraisons ») :
|
||||
* - branche CLIENT : l'adresse de livraison, identifiee par la raison sociale
|
||||
* du client ;
|
||||
* - branche FOURNISSEUR : le site de livraison (un des 3 sites 86/17/82).
|
||||
*/
|
||||
private function formatDelivery(CarrierPrice $price): string
|
||||
{
|
||||
if ('CLIENT' === $price->getDirection()) {
|
||||
return $price->getClientDeliveryAddress()?->getClient()?->getCompanyName() ?? '';
|
||||
}
|
||||
|
||||
return $price->getDeliverySite()?->getName() ?? '';
|
||||
}
|
||||
|
||||
private function buildResponse(Carrier $carrier, string $binary): Response
|
||||
{
|
||||
$filename = sprintf('prix-transporteur-%d-%s.xlsx', (int) $carrier->getId(), new DateTimeImmutable()->format('Ymd'));
|
||||
|
||||
$response = new Response($binary);
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Module\Commercial\Infrastructure\DataFixtures\ClientFixtures;
|
||||
use App\Module\Commercial\Infrastructure\DataFixtures\SupplierFixtures;
|
||||
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
|
||||
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Entity\CarrierAddress;
|
||||
use App\Module\Transport\Domain\Entity\CarrierContact;
|
||||
use App\Module\Transport\Domain\Entity\CarrierPrice;
|
||||
use App\Module\Transport\Domain\Entity\QualimatCarrier;
|
||||
use App\Shared\Domain\Contract\ClientAddressInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SiteProviderInterface;
|
||||
use App\Shared\Domain\Contract\SupplierAddressInterface;
|
||||
use App\Shared\Domain\Entity\UploadedDocument;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Fixtures dev/demo du repertoire transporteurs (M4) couvrant l'ensemble des cas
|
||||
* metier RG-4.xx, jumelles des fixtures fournisseurs (M2). C'est ICI que vivent
|
||||
* les fixtures COMPLETES (les maillons WT precedents s'etaient limites a un stub
|
||||
* de lecture). Cas pivots seedes (§ 8.4) :
|
||||
* - 1 transporteur QUALIMAT (lien `qualimat_carrier` + adresse copiee +
|
||||
* validityDate PASSEE pour exercer le fond rouge RG-4.04) ;
|
||||
* - 1 transporteur AUTRE + Decharge (UploadedDocument, RG-4.02) ;
|
||||
* - 1 transporteur affrete (indexation + benne + volume obligatoires, RG-4.03) ;
|
||||
* - 1 transporteur LIOT (immatriculations, certification non requise, RG-4.01) ;
|
||||
* - 1 transporteur COMPLET : contacts + adresses + prix CLIENT et FOURNISSEUR ;
|
||||
* - 1 transporteur archive (exclusion liste + restauration, RG-4.14).
|
||||
*
|
||||
* Resolution inter-modules conforme a la regle n°1 (pas d'import de logique) :
|
||||
* - sites resolus via le contrat Shared SiteProviderInterface ;
|
||||
* - client/adresse et fournisseur/adresse des prix resolus via les contrats
|
||||
* Shared ClientAddressInterface / SupplierAddressInterface (relations ORM
|
||||
* partagees, RG-4.10/4.11). Si la demo Commercial/Sites n'est pas chargee, les
|
||||
* prix sont simplement omis (le reste de la fiche reste seede).
|
||||
*
|
||||
* Normalisation : valeurs fournies BRUTES puis normalisees par
|
||||
* CarrierFieldNormalizer avant persist, comme le ferait le CarrierProcessor via
|
||||
* l'API (name UPPERCASE, first/last Capitalize, telephones chiffres seuls, email
|
||||
* lowercase, liotPlates « ; »-normalise).
|
||||
*
|
||||
* Idempotence : lookup par `name` normalise (coherent avec l'index unique partiel
|
||||
* uq_carrier_name_active). Un transporteur deja present n'est pas reconstruit (ses
|
||||
* sous-collections ne sont pas redupliquees). Rejouable sans doublon.
|
||||
*
|
||||
* Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
|
||||
* restent null (« Systeme » cote front), c'est attendu.
|
||||
*
|
||||
* Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`, la
|
||||
* fixture ne charge rien : les tests seedent et nettoient leurs propres
|
||||
* transporteurs et comptent sur une table `carrier` vierge — y injecter des
|
||||
* transporteurs de demo casserait les comptages de liste et les cleanups. Meme
|
||||
* garde-fou que ClientFixtures / SupplierFixtures.
|
||||
*/
|
||||
class CarrierFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
/** SIRET de la ligne qualimat_carrier de demo (cle naturelle, insert idempotent). */
|
||||
private const string QUALIMAT_DEMO_SIRET = '90000000000017';
|
||||
|
||||
public function __construct(
|
||||
private readonly CarrierFieldNormalizer $normalizer,
|
||||
private readonly SiteProviderInterface $siteProvider,
|
||||
#[Autowire('%kernel.environment%')]
|
||||
private readonly string $environment,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, class-string>
|
||||
*/
|
||||
public function getDependencies(): array
|
||||
{
|
||||
// Les prix referencent des Client/Supplier/Site de demo (relations ORM
|
||||
// partagees) : ces fixtures doivent tourner avant.
|
||||
return [
|
||||
SitesFixtures::class,
|
||||
ClientFixtures::class,
|
||||
SupplierFixtures::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// Donnees de demo : dev uniquement. En test, on laisse la table vierge.
|
||||
if ('test' === $this->environment) {
|
||||
return;
|
||||
}
|
||||
|
||||
// === Transporteur QUALIMAT (RG-4.01) — adresse copiee + validite PASSEE (RG-4.04) ===
|
||||
[$grelillier, $isNew] = $this->ensureCarrier($manager, 'Transports Grelillier');
|
||||
if ($isNew) {
|
||||
$grelillier->setQualimatCarrier($this->ensureQualimatDemoLine($manager));
|
||||
$grelillier->setCertificationType('QUALIMAT');
|
||||
// Adresse pre-remplie depuis la copie QUALIMAT (RG-4.05).
|
||||
$this->addAddress($grelillier, '86000', 'Poitiers', '12 rue des Acacias');
|
||||
$this->addContact($grelillier, 'Marie', 'Martin', 'Exploitation', '06 12 34 56 78', null, 'marie.martin@grelillier.fr');
|
||||
}
|
||||
|
||||
// === Transporteur AUTRE + Decharge (RG-4.02) ===
|
||||
[$pandele, $isNew] = $this->ensureCarrier($manager, 'Transports Pandele');
|
||||
if ($isNew) {
|
||||
$pandele->setCertificationType('AUTRE');
|
||||
$pandele->setDischargeDocument($this->buildDischargeDocument($manager));
|
||||
$this->addContact($pandele, 'Luc', 'Pandele', 'Gerant', '05 49 11 22 33', null, 'luc.pandele@pandele.fr');
|
||||
}
|
||||
|
||||
// === Transporteur affrete (RG-4.03) — indexation + benne + volume ===
|
||||
[$affrete, $isNew] = $this->ensureCarrier($manager, 'Affreteurs Reunis');
|
||||
if ($isNew) {
|
||||
$affrete->setCertificationType('GMP_PLUS');
|
||||
$affrete->setIsChartered(true);
|
||||
$affrete->setIndexationRate('5.00');
|
||||
$affrete->setContainerType('BENNE');
|
||||
$affrete->setVolumeM3('90.00');
|
||||
$this->addAddress($affrete, '17000', 'La Rochelle', '4 quai des Affreteurs');
|
||||
}
|
||||
|
||||
// === Cas LIOT (RG-4.01) — immatriculations, certification non requise ===
|
||||
[$liot, $isNew] = $this->ensureCarrier($manager, 'LIOT');
|
||||
if ($isNew) {
|
||||
$liot->setLiotPlates($this->normalizer->normalizeLiotPlates('ab-123-cd ; ef-456-gh ; gh-789-ij'));
|
||||
}
|
||||
|
||||
// === Transporteur COMPLET — contacts + adresses + prix CLIENT et FOURNISSEUR ===
|
||||
[$complet, $isNew] = $this->ensureCarrier($manager, 'Transports Logistique Globale');
|
||||
if ($isNew) {
|
||||
$complet->setCertificationType('OVOCOM');
|
||||
$this->addAddress($complet, '86100', 'Châtellerault', '20 zone des Transporteurs');
|
||||
$this->addContact($complet, 'Sophie', 'Bernard', 'Directrice', '05 49 44 55 66', '06 99 88 77 66', 'sophie.bernard@logistique-globale.fr', 0);
|
||||
$this->addContact($complet, 'Marc', 'Lopez', 'Affretement', '05 49 44 55 67', null, 'marc.lopez@logistique-globale.fr', 1);
|
||||
$this->addPrices($manager, $complet);
|
||||
}
|
||||
|
||||
// === Transporteur archive (RG-4.14) ===
|
||||
[$archive, $isNew] = $this->ensureCarrier($manager, 'Transports Anciens', isArchived: true);
|
||||
if ($isNew) {
|
||||
$archive->setCertificationType('COMPTE_PROPRE');
|
||||
$this->addContact($archive, 'Paul', 'Ancien', 'Ex-gerant', '05 49 00 00 00', null, 'paul.ancien@anciens.fr');
|
||||
}
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un transporteur (nom normalise UPPERCASE) s'il n'existe pas encore,
|
||||
* sinon retourne l'existant. Retourne [Carrier, isNew] : isNew=false bloque la
|
||||
* reconstruction des sous-collections (idempotence sans doublon).
|
||||
*
|
||||
* @return array{0: Carrier, 1: bool}
|
||||
*/
|
||||
private function ensureCarrier(ObjectManager $manager, string $name, bool $isArchived = false): array
|
||||
{
|
||||
$normalizedName = (string) $this->normalizer->normalizeName($name);
|
||||
|
||||
$existing = $manager->getRepository(Carrier::class)->findOneBy(['name' => $normalizedName]);
|
||||
if ($existing instanceof Carrier) {
|
||||
return [$existing, false];
|
||||
}
|
||||
|
||||
$carrier = new Carrier();
|
||||
$carrier->setName($normalizedName);
|
||||
|
||||
if ($isArchived) {
|
||||
$carrier->setIsArchived(true);
|
||||
$carrier->setArchivedAt(new DateTimeImmutable());
|
||||
}
|
||||
|
||||
$manager->persist($carrier);
|
||||
|
||||
return [$carrier, true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une adresse au transporteur (cascade persist via Carrier.addresses).
|
||||
*/
|
||||
private function addAddress(Carrier $carrier, string $postalCode, string $city, string $street): void
|
||||
{
|
||||
$address = new CarrierAddress();
|
||||
$address->setPostalCode($postalCode);
|
||||
$address->setCity($city);
|
||||
$address->setStreet($street);
|
||||
$carrier->addAddress($address);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un contact normalise au transporteur (cascade persist via
|
||||
* Carrier.contacts). Au moins un champ est toujours fourni (RG-4.08).
|
||||
*/
|
||||
private function addContact(
|
||||
Carrier $carrier,
|
||||
?string $firstName,
|
||||
?string $lastName,
|
||||
?string $jobTitle,
|
||||
?string $phonePrimary,
|
||||
?string $phoneSecondary,
|
||||
?string $email,
|
||||
int $position = 0,
|
||||
): void {
|
||||
$contact = new CarrierContact();
|
||||
$contact->setFirstName($this->normalizer->normalizePersonName($firstName));
|
||||
$contact->setLastName($this->normalizer->normalizePersonName($lastName));
|
||||
$contact->setJobTitle($jobTitle);
|
||||
$contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary));
|
||||
$contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary));
|
||||
$contact->setEmail($this->normalizer->normalizeEmail($email));
|
||||
$contact->setPosition($position);
|
||||
|
||||
$carrier->addContact($contact);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un prix CLIENT et un prix FOURNISSEUR au transporteur (RG-4.10/4.11),
|
||||
* en resolvant les relations cross-module (client/adresse de livraison + site
|
||||
* de depart ; fournisseur/adresse d'appro + site de livraison) via les contrats
|
||||
* Shared. Si la demo Commercial/Sites n'est pas disponible, les prix sont omis.
|
||||
*/
|
||||
private function addPrices(ObjectManager $manager, Carrier $carrier): void
|
||||
{
|
||||
$site = $this->siteProvider->findByName('Chatellerault');
|
||||
|
||||
// Branche CLIENT (RG-4.10) : 1ere adresse de livraison de la demo M1.
|
||||
$clientAddress = $manager->getRepository(ClientAddressInterface::class)->findOneBy(['isDelivery' => true]);
|
||||
if ($site instanceof SiteInterface && $clientAddress instanceof ClientAddressInterface && null !== $clientAddress->getClient()) {
|
||||
$clientPrice = new CarrierPrice();
|
||||
$clientPrice->setDirection('CLIENT');
|
||||
$clientPrice->setClient($clientAddress->getClient());
|
||||
$clientPrice->setClientDeliveryAddress($clientAddress);
|
||||
$clientPrice->setDepartureSite($site);
|
||||
$clientPrice->setContainerType('BENNE');
|
||||
$clientPrice->setPricingUnit('TONNE');
|
||||
$clientPrice->setPrice('42.50');
|
||||
$clientPrice->setPriceState('VALIDE');
|
||||
$carrier->addPrice($clientPrice);
|
||||
}
|
||||
|
||||
// Branche FOURNISSEUR (RG-4.11) : 1ere adresse de DEPART de la demo M2.
|
||||
$supplierAddress = $manager->getRepository(SupplierAddressInterface::class)->findOneBy(['addressType' => 'DEPART']);
|
||||
if ($site instanceof SiteInterface && $supplierAddress instanceof SupplierAddressInterface && null !== $supplierAddress->getSupplier()) {
|
||||
$supplierPrice = new CarrierPrice();
|
||||
$supplierPrice->setDirection('FOURNISSEUR');
|
||||
$supplierPrice->setSupplier($supplierAddress->getSupplier());
|
||||
$supplierPrice->setSupplierSupplyAddress($supplierAddress);
|
||||
$supplierPrice->setDeliverySite($site);
|
||||
$supplierPrice->setContainerType('FOND_MOUVANT');
|
||||
$supplierPrice->setPricingUnit('FORFAIT');
|
||||
$supplierPrice->setPrice('320.00');
|
||||
$supplierPrice->setPriceState('EN_COURS');
|
||||
$carrier->addPrice($supplierPrice);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit (non persiste explicitement — cascade via la FK Carrier) un
|
||||
* UploadedDocument de demo pour la Decharge (RG-4.02). Pas de fichier reel sur
|
||||
* disque : metadonnees factices suffisantes pour la demo.
|
||||
*/
|
||||
private function buildDischargeDocument(ObjectManager $manager): UploadedDocument
|
||||
{
|
||||
$document = new UploadedDocument(
|
||||
'decharge-demo.pdf',
|
||||
'demo/decharge-demo.pdf',
|
||||
'application/pdf',
|
||||
12_345,
|
||||
str_repeat('0', 64),
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
$manager->persist($document);
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insere (idempotent, par SIRET) une ligne `qualimat_carrier` de demo a
|
||||
* validite PASSEE (RG-4.04) puis retourne l'entite (lecture seule) rechargee.
|
||||
* La table est normalement alimentee par `app:qualimat:sync` ; en demo on pose
|
||||
* une ligne directe en DBAL (l'entite mappee n'expose aucune ecriture API).
|
||||
*/
|
||||
private function ensureQualimatDemoLine(ObjectManager $manager): QualimatCarrier
|
||||
{
|
||||
$repository = $manager->getRepository(QualimatCarrier::class);
|
||||
$existing = $repository->findOneBy(['siret' => self::QUALIMAT_DEMO_SIRET]);
|
||||
if ($existing instanceof QualimatCarrier) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
if ($manager instanceof EntityManagerInterface) {
|
||||
$manager->getConnection()->insert('qualimat_carrier', [
|
||||
'siret' => self::QUALIMAT_DEMO_SIRET,
|
||||
'name' => 'TRANSPORTS GRELILLIER',
|
||||
'address' => '12 rue des Acacias',
|
||||
'postal_code' => '86000',
|
||||
'city' => 'Poitiers',
|
||||
'status' => 'Valide',
|
||||
// Validite PASSEE : exerce le fond rouge RG-4.04 cote front.
|
||||
'validity_date' => '2024-12-31',
|
||||
'is_active' => 'true',
|
||||
'last_synced_at' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
// @var QualimatCarrier $line
|
||||
return $repository->findOneBy(['siret' => self::QUALIMAT_DEMO_SIRET]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Repository\CarrierRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Carrier>
|
||||
*/
|
||||
class DoctrineCarrierRepository extends ServiceEntityRepository implements CarrierRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Carrier::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Carrier
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function save(Carrier $carrier): void
|
||||
{
|
||||
$this->getEntityManager()->persist($carrier);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function createListQueryBuilder(
|
||||
bool $includeArchived = false,
|
||||
?string $search = null,
|
||||
array $certificationTypes = [],
|
||||
bool $archivedOnly = false,
|
||||
): QueryBuilder {
|
||||
// Fetch-join de la SEULE relation ManyToOne qualimatCarrier (sur, pas de
|
||||
// cartesien) pour exposer statut/date de validite QUALIMAT en liste sans
|
||||
// N+1 (§ 2.11). Aucune sous-collection (addresses/contacts/prices) jointe
|
||||
// en liste : elles ne sont embarquees qu'au detail (carrier:item:read).
|
||||
$qb = $this->createQueryBuilder('c')
|
||||
->leftJoin('c.qualimatCarrier', 'q')->addSelect('q')
|
||||
->andWhere('c.deletedAt IS NULL')
|
||||
->orderBy('c.name', 'ASC')
|
||||
;
|
||||
|
||||
// Pas de cloisonnement par site (§ 2.3) : referentiel global.
|
||||
// Perimetre d'archivage : archivedOnly prioritaire sur includeArchived
|
||||
// (jumeau de DoctrineProviderRepository — toggle « Voir les archives »).
|
||||
if ($archivedOnly) {
|
||||
$qb->andWhere('c.isArchived = true');
|
||||
} elseif (!$includeArchived) {
|
||||
$qb->andWhere('c.isArchived = false');
|
||||
}
|
||||
|
||||
$this->applySearch($qb, $search);
|
||||
$this->applyCertificationTypes($qb, $certificationTypes);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche fuzzy insensible a la casse sur le nom du transporteur (§ 4.1).
|
||||
* Metacaracteres LIKE (%, _, \) echappes pour rester litteraux.
|
||||
*/
|
||||
private function applySearch(QueryBuilder $qb, ?string $search): void
|
||||
{
|
||||
if (null === $search || '' === trim($search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||
|
||||
$qb->andWhere('LOWER(c.name) LIKE :search')
|
||||
->setParameter('search', $pattern)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restreint aux transporteurs dont la certification figure dans la liste (OR).
|
||||
* Alimente le filtre « Certification » de la liste (§ 4.1).
|
||||
*
|
||||
* @param list<string> $certificationTypes
|
||||
*/
|
||||
private function applyCertificationTypes(QueryBuilder $qb, array $certificationTypes): void
|
||||
{
|
||||
$codes = [];
|
||||
foreach ($certificationTypes as $code) {
|
||||
if (is_string($code) && '' !== trim($code)) {
|
||||
$codes[] = trim($code);
|
||||
}
|
||||
}
|
||||
|
||||
if ([] === $codes) {
|
||||
return;
|
||||
}
|
||||
|
||||
$qb->andWhere('c.certificationType IN (:certificationTypes)')
|
||||
->setParameter('certificationTypes', $codes)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Transport\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Transport\Domain\Entity\QualimatCarrier;
|
||||
use App\Module\Transport\Domain\Repository\QualimatCarrierRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<QualimatCarrier>
|
||||
*/
|
||||
class DoctrineQualimatCarrierRepository extends ServiceEntityRepository implements QualimatCarrierRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, QualimatCarrier::class);
|
||||
}
|
||||
|
||||
public function createSearchQueryBuilder(?string $search = null): QueryBuilder
|
||||
{
|
||||
// Saisie assistee (§ 4.7) : on ne propose QUE des transporteurs QUALIMAT
|
||||
// actifs (is_active = true), tries par nom. Le forcage de l'actif est une
|
||||
// regle serveur (pas un filtre client) — les lignes soft-deletees par la
|
||||
// synchro restent invisibles.
|
||||
$qb = $this->createQueryBuilder('q')
|
||||
->andWhere('q.isActive = true')
|
||||
->orderBy('q.name', 'ASC')
|
||||
;
|
||||
|
||||
$this->applySearch($qb, $search);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche fuzzy insensible a la casse sur le nom (+ siret) du transporteur
|
||||
* QUALIMAT (§ 4.7 / RG-4.01). Metacaracteres LIKE (%, _, \) echappes pour
|
||||
* rester litteraux.
|
||||
*/
|
||||
private function applySearch(QueryBuilder $qb, ?string $search): void
|
||||
{
|
||||
if (null === $search || '' === trim($search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||
|
||||
$qb->andWhere('LOWER(q.name) LIKE :search OR LOWER(q.siret) LIKE :search')
|
||||
->setParameter('search', $pattern)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -13,17 +13,22 @@ final class TransportModule
|
||||
/**
|
||||
* Liste declarative des permissions RBAC exposees par le module Transport.
|
||||
*
|
||||
* Vide a ce stade : le module ne porte que des referentiels externes
|
||||
* synchronises par commandes console (codes IDTF - ERP-149, transporteurs
|
||||
* QUALIMAT - ERP-39), sans ecran ni action protegee. Les permissions seront
|
||||
* ajoutees quand une page de consultation sera exposee.
|
||||
* Socle du repertoire transporteurs (M4 § 5.1, ERP-153) :
|
||||
* - `view` : consultation de la liste / fiche transporteur ;
|
||||
* - `manage` : creation / modification (hors archivage) ;
|
||||
* - `archive` : archivage / restauration (admin seul, cf. matrice § 5.2).
|
||||
*
|
||||
* Consommee par `app:sync-permissions` (un tableau vide est valide).
|
||||
* Consommee par `app:sync-permissions`. Matrice role -> permissions dans
|
||||
* `RbacSeeder::MATRIX` (§ 5.2).
|
||||
*
|
||||
* @return array<int, array{code: string, label: string}>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [];
|
||||
return [
|
||||
['code' => 'transport.carriers.view', 'label' => 'Voir les transporteurs'],
|
||||
['code' => 'transport.carriers.manage', 'label' => 'Créer / modifier les transporteurs'],
|
||||
['code' => 'transport.carriers.archive', 'label' => 'Archiver / restaurer un transporteur'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Contrat minimal d'une adresse de Client (M1 Commercial) exposable a un autre
|
||||
* module sans couplage direct (regle ABSOLUE n°1). Mappe vers
|
||||
* App\Module\Commercial\Domain\Entity\ClientAddress via `resolve_target_entities`.
|
||||
*
|
||||
* Implemente par App\Module\Commercial\Domain\Entity\ClientAddress. Utilise
|
||||
* comme type-hint des relations ORM cross-module (ex: CarrierPrice.clientDeliveryAddress,
|
||||
* M4). La serialisation passe par le read-group de l'entite concrete
|
||||
* (client_address:read), pas par cette interface.
|
||||
*/
|
||||
interface ClientAddressInterface
|
||||
{
|
||||
public function getId(): ?int;
|
||||
|
||||
/**
|
||||
* Client parent de l'adresse. Expose le lien inverse sans coupler au module
|
||||
* Commercial : permet a un autre module de verifier l'appartenance d'une
|
||||
* adresse a un client (ex: CarrierPrice, RG-4.10 — l'adresse de livraison
|
||||
* doit appartenir au client choisi). Retour covariant ?Client cote entite.
|
||||
*/
|
||||
public function getClient(): ?ClientInterface;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Contrat minimal exposant ce qu'un autre module doit connaitre d'un Client
|
||||
* (M1 Commercial) sans creer de couplage direct vers le module Commercial
|
||||
* (regle ABSOLUE n°1). Mappe vers App\Module\Commercial\Domain\Entity\Client
|
||||
* via `resolve_target_entities` (doctrine.yaml).
|
||||
*
|
||||
* Implemente par App\Module\Commercial\Domain\Entity\Client. Utilise comme
|
||||
* type-hint dans les relations ORM cross-module (ex: CarrierPrice.client, M4).
|
||||
* La serialisation passe par les read-groups de l'entite concrete (client:read),
|
||||
* pas par cette interface.
|
||||
*/
|
||||
interface ClientInterface
|
||||
{
|
||||
public function getId(): ?int;
|
||||
|
||||
public function getCompanyName(): ?string;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Contrat minimal d'une adresse de Supplier (M2 Commercial) exposable a un autre
|
||||
* module sans couplage direct (regle ABSOLUE n°1). Mappe vers
|
||||
* App\Module\Commercial\Domain\Entity\SupplierAddress via `resolve_target_entities`.
|
||||
*
|
||||
* Implemente par App\Module\Commercial\Domain\Entity\SupplierAddress. Utilise
|
||||
* comme type-hint des relations ORM cross-module (ex: CarrierPrice.supplierSupplyAddress,
|
||||
* M4). La serialisation passe par le read-group de l'entite concrete
|
||||
* (supplier_address:read), pas par cette interface.
|
||||
*/
|
||||
interface SupplierAddressInterface
|
||||
{
|
||||
public function getId(): ?int;
|
||||
|
||||
/**
|
||||
* Fournisseur parent de l'adresse. Expose le lien inverse sans coupler au
|
||||
* module Commercial : permet a un autre module de verifier l'appartenance
|
||||
* d'une adresse a un fournisseur (ex: CarrierPrice, RG-4.11 — l'adresse
|
||||
* d'appro doit appartenir au fournisseur choisi). Retour covariant ?Supplier.
|
||||
*/
|
||||
public function getSupplier(): ?SupplierInterface;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Contrat minimal exposant ce qu'un autre module doit connaitre d'un Supplier
|
||||
* (M2 Commercial) sans creer de couplage direct vers le module Commercial
|
||||
* (regle ABSOLUE n°1). Mappe vers App\Module\Commercial\Domain\Entity\Supplier
|
||||
* via `resolve_target_entities` (doctrine.yaml).
|
||||
*
|
||||
* Implemente par App\Module\Commercial\Domain\Entity\Supplier. Utilise comme
|
||||
* type-hint dans les relations ORM cross-module (ex: CarrierPrice.supplier, M4).
|
||||
* La serialisation passe par les read-groups de l'entite concrete (supplier:read),
|
||||
* pas par cette interface.
|
||||
*/
|
||||
interface SupplierInterface
|
||||
{
|
||||
public function getId(): ?int;
|
||||
|
||||
public function getCompanyName(): ?string;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Shared\Infrastructure\ApiPlatform\State\UploadedDocumentProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Reference technique d'un fichier televerse (infra generique Shared, ERP-154).
|
||||
*
|
||||
* Entite IMMUABLE : un document n'est jamais modifie apres creation (pas d'onglet
|
||||
* edition cote front). Elle ne porte donc QUE `created_at` / `created_by` — pas
|
||||
* la paire `updated_*` — et n'implemente volontairement pas Timestampable /
|
||||
* Blamable (qui imposeraient les 4 colonnes). `created_at` est rempli par le
|
||||
* FileUploader via l'horloge injectee ; `created_by` est positionne par le
|
||||
* processor depuis l'utilisateur authentifie (null hors HTTP).
|
||||
*
|
||||
* Pas de `#[Auditable]` : c'est un enregistrement d'infrastructure (et non un
|
||||
* agregat metier edite), sa tracabilite est portee par created_at / created_by.
|
||||
*
|
||||
* Operations API :
|
||||
* - Post (/uploaded_documents, multipart) : `deserialize: false` — le binaire
|
||||
* n'est pas deserialise dans l'entite, le UploadedDocumentProcessor lit le
|
||||
* fichier de la requete, delegue au FileUploader (validation MIME server-side,
|
||||
* bornage taille, checksum, ecriture disque) puis persiste. MIME hors
|
||||
* whitelist -> 422.
|
||||
* - Get (/uploaded_documents/{id}) : necessaire pour qu'API Platform genere
|
||||
* l'IRI renvoyee par le Post. Protege par IS_AUTHENTICATED_FULLY uniquement
|
||||
* (pas de RBAC ni de cloisonnement tenant ici) : cette ressource est une
|
||||
* infra GENERIQUE qui ne porte aucune notion de proprietaire metier. Le
|
||||
* cloisonnement d'acces (qui peut voir quel document) est volontairement
|
||||
* delegue au module CONSOMMATEUR (ex: la Decharge M4), qui exposera le
|
||||
* document via sa propre ressource cloisonnee plutot que via cet endpoint
|
||||
* technique. Ne renvoie que des metadonnees (jamais le binaire).
|
||||
*
|
||||
* Pas de GetCollection exposee (non requise) — la regle de pagination ne
|
||||
* s'applique donc pas ici.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'uploaded_document')]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('IS_AUTHENTICATED_FULLY')",
|
||||
),
|
||||
new Post(
|
||||
// Entree multipart : le binaire arrive en multipart/form-data sous
|
||||
// le champ « file ». Sans cet inputFormats, API Platform rejette la
|
||||
// requete en 415.
|
||||
inputFormats: ['multipart' => ['multipart/form-data']],
|
||||
// Le fichier n'est pas deserialisable dans l'entite : le processor
|
||||
// lit le binaire de la requete. La validation est portee par le
|
||||
// FileUploader (MIME server-side, taille), pas par les contraintes.
|
||||
deserialize: false,
|
||||
validate: false,
|
||||
security: "is_granted('IS_AUTHENTICATED_FULLY')",
|
||||
processor: UploadedDocumentProcessor::class,
|
||||
),
|
||||
],
|
||||
normalizationContext: ['groups' => ['uploaded_document:read']],
|
||||
)]
|
||||
class UploadedDocument
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(name: 'original_filename', length: 255)]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private string $originalFilename;
|
||||
|
||||
#[ORM\Column(name: 'stored_path', length: 512)]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private string $storedPath;
|
||||
|
||||
#[ORM\Column(name: 'mime_type', length: 100)]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private string $mimeType;
|
||||
|
||||
#[ORM\Column(name: 'size_bytes', type: 'integer')]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private int $sizeBytes;
|
||||
|
||||
#[ORM\Column(name: 'checksum', length: 64)]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private string $checksum;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'datetime_immutable')]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?UserInterface $createdBy = null;
|
||||
|
||||
public function __construct(
|
||||
string $originalFilename,
|
||||
string $storedPath,
|
||||
string $mimeType,
|
||||
int $sizeBytes,
|
||||
string $checksum,
|
||||
DateTimeImmutable $createdAt,
|
||||
) {
|
||||
$this->originalFilename = $originalFilename;
|
||||
$this->storedPath = $storedPath;
|
||||
$this->mimeType = $mimeType;
|
||||
$this->sizeBytes = $sizeBytes;
|
||||
$this->checksum = $checksum;
|
||||
$this->createdAt = $createdAt;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getOriginalFilename(): string
|
||||
{
|
||||
return $this->originalFilename;
|
||||
}
|
||||
|
||||
public function getStoredPath(): string
|
||||
{
|
||||
return $this->storedPath;
|
||||
}
|
||||
|
||||
public function getMimeType(): string
|
||||
{
|
||||
return $this->mimeType;
|
||||
}
|
||||
|
||||
public function getSizeBytes(): int
|
||||
{
|
||||
return $this->sizeBytes;
|
||||
}
|
||||
|
||||
public function getChecksum(): string
|
||||
{
|
||||
return $this->checksum;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getCreatedBy(): ?UserInterface
|
||||
{
|
||||
return $this->createdBy;
|
||||
}
|
||||
|
||||
public function setCreatedBy(?UserInterface $user): void
|
||||
{
|
||||
$this->createdBy = $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Exception;
|
||||
|
||||
/**
|
||||
* Levee quand le fichier televerse depasse la taille maximale autorisee
|
||||
* (FileUploader::MAX_SIZE_BYTES). Traduite en HTTP 422 par le processor.
|
||||
*/
|
||||
final class FileTooLargeException extends FileUploadException
|
||||
{
|
||||
public function __construct(int $size, int $maxSize)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'Le fichier (%d octets) dépasse la taille maximale autorisée (%d octets).',
|
||||
$size,
|
||||
$maxSize,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Exception de base des erreurs de televersement (FileUploader).
|
||||
*
|
||||
* Decouplee de HTTP : le service Shared\Infrastructure\Upload\FileUploader leve
|
||||
* une de ces exceptions metier, et c'est la couche API (UploadedDocumentProcessor)
|
||||
* qui la traduit en reponse HTTP 422.
|
||||
*/
|
||||
class FileUploadException extends RuntimeException {}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Exception;
|
||||
|
||||
/**
|
||||
* Levee quand le type MIME detecte server-side n'appartient pas a la whitelist
|
||||
* du FileUploader (PDF + images). Traduite en HTTP 422 par le processor.
|
||||
*/
|
||||
final class UnsupportedMimeTypeException extends FileUploadException
|
||||
{
|
||||
/**
|
||||
* @param list<string> $allowed Types MIME autorises
|
||||
*/
|
||||
public function __construct(string $mimeType, array $allowed)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'Le type de fichier « %s » n\'est pas autorisé. Types acceptés : %s.',
|
||||
$mimeType,
|
||||
implode(', ', $allowed),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Shared\Domain\Exception\FileUploadException;
|
||||
use App\Shared\Infrastructure\Upload\FileUploader;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de l'upload generique (POST /api/uploaded_documents).
|
||||
*
|
||||
* L'operation Post est en `deserialize: false` : le binaire n'est pas mappe sur
|
||||
* l'entite. Ce processor lit le fichier multipart de la requete (champ « file »),
|
||||
* delegue au FileUploader (validation MIME server-side, bornage taille, checksum,
|
||||
* ecriture disque), positionne l'auteur (created_by) puis persiste via le
|
||||
* processor Doctrine standard. Le retour est l'entite, qu'API Platform serialise
|
||||
* en JSON-LD (avec son @id / IRI).
|
||||
*
|
||||
* Mapping des erreurs :
|
||||
* - fichier absent -> 422 ;
|
||||
* - MIME hors whitelist / fichier trop volumineux (FileUploadException) -> 422.
|
||||
*
|
||||
* Si la persistance Doctrine echoue APRES l'ecriture disque, le fichier physique
|
||||
* deja deplace est supprime (compensation) pour ne pas laisser de binaire orphelin.
|
||||
*
|
||||
* @implements ProcessorInterface<mixed, mixed>
|
||||
*/
|
||||
final class UploadedDocumentProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly FileUploader $fileUploader,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$file = $request?->files->get('file');
|
||||
|
||||
if (!$file instanceof UploadedFile) {
|
||||
throw new UnprocessableEntityHttpException('Aucun fichier fourni (champ « file » attendu).');
|
||||
}
|
||||
|
||||
try {
|
||||
$document = $this->fileUploader->upload($file);
|
||||
} catch (FileUploadException $e) {
|
||||
// MIME hors whitelist ou fichier trop volumineux -> 422 avec le
|
||||
// message metier explicite porte par l'exception.
|
||||
throw new UnprocessableEntityHttpException($e->getMessage(), $e);
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if ($user instanceof UserInterface) {
|
||||
$document->setCreatedBy($user);
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->persistProcessor->process($document, $operation, $uriVariables, $context);
|
||||
} catch (Throwable $e) {
|
||||
// La persistance a echoue APRES l'ecriture disque (erreur DB, FK...) :
|
||||
// on supprime le fichier orphelin pour ne pas le laisser sans ligne
|
||||
// uploaded_document correspondante, puis on relaie l'erreur.
|
||||
$this->fileUploader->remove($document);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,18 @@ final class ColumnCommentsCatalog
|
||||
public static function comments(): array
|
||||
{
|
||||
return [
|
||||
'uploaded_document' => [
|
||||
'_table' => 'Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'original_filename' => 'Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.',
|
||||
'stored_path' => 'Chemin relatif du fichier sous var/uploads (ex: 2026/06/<hash>.pdf) — nom genere aleatoirement, jamais le nom client.',
|
||||
'mime_type' => 'Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).',
|
||||
'size_bytes' => 'Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.',
|
||||
'checksum' => 'Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).',
|
||||
'created_at' => 'Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).',
|
||||
'created_by' => 'ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.',
|
||||
],
|
||||
|
||||
'audit_log' => [
|
||||
'_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.",
|
||||
'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).",
|
||||
@@ -446,6 +458,86 @@ final class ColumnCommentsCatalog
|
||||
'iban' => 'IBAN du compte (≤ 34 caracteres).',
|
||||
'position' => 'Ordre d affichage du RIB dans la liste du prestataire (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
// === M4 Transport — referentiel QUALIMAT (ERP-39, mappe lecture seule des ERP-155) ===
|
||||
// Mappe par l'entite QualimatCarrier depuis M4 -> retire du schema_filter,
|
||||
// donc ses COMMENT sont rejoues par app:apply-column-comments apres schema:update.
|
||||
'qualimat_carrier' => [
|
||||
'_table' => "Referentiel des transporteurs agrees QUALIMAT, synchronise quotidiennement depuis l'API qualimat.org (type=operateur_transport).",
|
||||
'id' => 'Cle technique auto-incrementee.',
|
||||
'siret' => 'SIRET normalise (chiffres sans espaces). Cle naturelle de synchro (unique). Source parfois incomplete (longueur variable), non contrainte a 14.',
|
||||
'name' => 'Raison sociale du transporteur (champs Nom = Societe de la source, identiques).',
|
||||
'address' => 'Adresse postale (voie). Nullable.',
|
||||
'postal_code' => 'Code postal. Nullable.',
|
||||
'city' => 'Ville. Nullable.',
|
||||
'phone' => 'Telephone au format source "indicatif|numero" (ex: +33|0608890316). Nullable.',
|
||||
'department' => 'Departement au format source "code - libelle" (ex: 65 - Hautes-Pyrenees). Nullable.',
|
||||
'status' => "Statut d'agrement QUALIMAT (valeurs connues : Audite, Valide, Suspendu). Valeur brute de la source, non contrainte.",
|
||||
'validity_date' => 'Date de fin de validite de la certification (convertie depuis dd/mm/yyyy). Nullable.',
|
||||
'is_active' => 'Faux = transporteur absent du dernier import (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.',
|
||||
'last_synced_at' => 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).',
|
||||
],
|
||||
|
||||
// === M4 Transport — repertoire transporteurs (ERP-155/157) ===
|
||||
'carrier' => [
|
||||
'_table' => 'Repertoire transporteurs (M4 Transport) — entites editables, archivables (is_archived) et soft-deletables (deleted_at). Distinct du referentiel qualimat_carrier.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'qualimat_carrier_id' => 'Lien editable vers le referentiel QUALIMAT (saisie assistee RG-4.01). FK -> qualimat_carrier.id, ON DELETE SET NULL : transporteur conserve si la ligne QUALIMAT disparait.',
|
||||
'name' => 'Raison sociale du transporteur (stockee en MAJUSCULES). Unique case-insensitive parmi les non-archives/non-supprimes (uq_carrier_name_active, RG-4.12 / § 2.6).',
|
||||
'certification_type' => 'Type de certification : QUALIMAT (si lie, lecture seule) ou GMP_PLUS/OVOCOM/COMPTE_PROPRE/AUTRE. AUTRE declenche le champ Decharge (RG-4.02). Null en cas LIOT (RG-4.01).',
|
||||
'is_chartered' => '« Affreter » coche : declenche indexation/benne-fond mouvant/volume, obligatoires (RG-4.03). Faux par defaut.',
|
||||
'indexation_rate' => 'Taux d indexation en pourcentage (NUMERIC 5,2) — renseigne si affrete (RG-4.03).',
|
||||
'container_type' => 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_container_type) — renseigne si affrete (RG-4.03).',
|
||||
'volume_m3' => 'Volume en m3 (NUMERIC 10,2) — renseigne si affrete (RG-4.03).',
|
||||
'discharge_document_id' => 'Document de Decharge (visible si certification_type = AUTRE, RG-4.02). FK -> uploaded_document.id (infra Shared § 2.7), ON DELETE SET NULL.',
|
||||
'liot_plates' => 'Immatriculations LIOT separees par « ; » (cas special nom=LIOT, RG-4.01). Les autres champs sont masques dans ce cas.',
|
||||
'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission transport.carriers.archive (Admin seul).',
|
||||
'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.',
|
||||
'deleted_at' => 'Horodatage du soft-delete technique — non expose par l API au M4. Null = ligne active.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'carrier_address' => [
|
||||
'_table' => 'Adresses d un transporteur (1:n) — onglet Adresse (M4). Pre-remplie depuis QUALIMAT si applicable (RG-4.05).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire de l adresse.',
|
||||
'country' => 'Pays de l adresse — defaut France.',
|
||||
'postal_code' => 'Code postal (saisie assistee BAN cote front, RG-4.06).',
|
||||
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front.',
|
||||
'street' => 'Numero et voie de l adresse.',
|
||||
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
|
||||
'position' => 'Ordre d affichage de l adresse dans la liste du transporteur (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'carrier_contact' => [
|
||||
'_table' => 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins un champ rempli (RG-4.08, chk_carrier_contact_filled), max 2 telephones.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.',
|
||||
'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).',
|
||||
'last_name' => 'Nom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).',
|
||||
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
|
||||
'phone_primary' => 'Telephone principal — chiffres uniquement (normalisation serveur).',
|
||||
'phone_secondary' => 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).',
|
||||
'email' => 'Email du contact (lowercase serveur).',
|
||||
'position' => 'Ordre d affichage du contact dans la liste du transporteur (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'carrier_price' => [
|
||||
'_table' => 'Prix d un transporteur (1:n) — onglet Prix (M4). Branche CLIENT ou FOURNISSEUR selon direction (RG-4.09->4.11, CHECK chk_carrier_price_*).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du prix.',
|
||||
'direction' => 'Sens du prix : CLIENT ou FOURNISSEUR (RG-4.09). Pilote l affichage et l obligation des colonnes client_*/supplier_* (RG-4.10/4.11).',
|
||||
'client_id' => 'Branche CLIENT (RG-4.10) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi direction = CLIENT.',
|
||||
'client_delivery_address_id' => 'Branche CLIENT : adresse de livraison du client. FK -> client_address.id, ON DELETE RESTRICT.',
|
||||
'departure_site_id' => 'Branche CLIENT : adresse de depart = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.',
|
||||
'supplier_id' => 'Branche FOURNISSEUR (RG-4.11) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi direction = FOURNISSEUR.',
|
||||
'supplier_supply_address_id' => 'Branche FOURNISSEUR : adresse d approvisionnement du fournisseur. FK -> supplier_address.id, ON DELETE RESTRICT.',
|
||||
'delivery_site_id' => 'Branche FOURNISSEUR : adresse de livraison = un des 3 sites (86/17/82). FK -> site.id, ON DELETE RESTRICT.',
|
||||
'container_type' => 'Type de contenant BENNE|FOND_MOUVANT (chk_carrier_price_container).',
|
||||
'pricing_unit' => 'Unite de tarification FORFAIT|TONNE (chk_carrier_price_unit).',
|
||||
'price' => 'Montant du prix (NUMERIC 12,2).',
|
||||
'price_state' => 'Etat du prix : EN_COURS, VALIDE ou NON_VALIDE (chk_carrier_price_state). Affiche dans le tableau Prix.',
|
||||
'position' => 'Ordre d affichage du prix dans la liste du transporteur (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Upload;
|
||||
|
||||
use App\Shared\Domain\Entity\UploadedDocument;
|
||||
use App\Shared\Domain\Exception\FileTooLargeException;
|
||||
use App\Shared\Domain\Exception\FileUploadException;
|
||||
use App\Shared\Domain\Exception\UnsupportedMimeTypeException;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
/**
|
||||
* Service generique de televersement de fichiers (infra Shared, ERP-154).
|
||||
*
|
||||
* Responsabilites :
|
||||
* - Validation du type MIME SERVER-SIDE via `getMimeType()` (detection finfo
|
||||
* sur le contenu reel) — JAMAIS `getClientMimeType()`, spoofable par le
|
||||
* client (regle backend.md « Upload de fichiers »).
|
||||
* - Whitelist MIME explicite (PDF + images courantes).
|
||||
* - Bornage de la taille (MAX_SIZE_BYTES).
|
||||
* - Calcul du checksum sha256 (controle d integrite) AVANT le deplacement.
|
||||
* - Ecriture disque sous `var/uploads/{yyyy}/{mm}/` avec un nom genere
|
||||
* aleatoirement (jamais le nom client, qui reste une simple metadonnee).
|
||||
*
|
||||
* Le service est volontairement decouple de HTTP au-dela du type UploadedFile :
|
||||
* il leve des exceptions metier (FileUploadException), traduites en 422 par le
|
||||
* UploadedDocumentProcessor.
|
||||
*/
|
||||
final class FileUploader
|
||||
{
|
||||
/**
|
||||
* Types MIME autorises (detectes server-side) : PDF + images courantes.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const ALLOWED_MIME_TYPES = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
];
|
||||
|
||||
/**
|
||||
* Taille maximale autorisee : 10 Mo.
|
||||
*/
|
||||
public const MAX_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
public function __construct(
|
||||
// Racine de stockage des fichiers televerses (hors web root, sous var/).
|
||||
#[Autowire('%kernel.project_dir%/var/uploads')]
|
||||
private readonly string $uploadBaseDir,
|
||||
private readonly ClockInterface $clock,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Valide, calcule l empreinte, deplace le fichier sur disque et retourne
|
||||
* un UploadedDocument NON persiste (le caller le persiste).
|
||||
*
|
||||
* @throws UnsupportedMimeTypeException si le MIME server-side est hors whitelist
|
||||
* @throws FileTooLargeException si le fichier depasse MAX_SIZE_BYTES
|
||||
*/
|
||||
public function upload(UploadedFile $file): UploadedDocument
|
||||
{
|
||||
// Detection MIME server-side (finfo sur le contenu) — jamais le MIME
|
||||
// declare par le client.
|
||||
$mimeType = $file->getMimeType() ?? 'application/octet-stream';
|
||||
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
|
||||
throw new UnsupportedMimeTypeException($mimeType, self::ALLOWED_MIME_TYPES);
|
||||
}
|
||||
|
||||
// getSize() peut renvoyer false si le fichier est illisible.
|
||||
$size = $file->getSize();
|
||||
if (false === $size || $size > self::MAX_SIZE_BYTES) {
|
||||
throw new FileTooLargeException(false === $size ? 0 : $size, self::MAX_SIZE_BYTES);
|
||||
}
|
||||
|
||||
// Checksum AVANT le move : le chemin du fichier change apres deplacement.
|
||||
// hash_file renvoie false si le fichier temporaire est illisible (I/O) :
|
||||
// on echoue proprement plutot que de propager un TypeError opaque au
|
||||
// constructeur (parametre $checksum type string).
|
||||
$checksum = hash_file('sha256', $file->getPathname());
|
||||
if (false === $checksum) {
|
||||
throw new FileUploadException('Impossible de lire le fichier televerse pour en calculer l\'empreinte.');
|
||||
}
|
||||
|
||||
$now = $this->clock->now();
|
||||
$relativeDir = $now->format('Y').'/'.$now->format('m');
|
||||
$targetDir = $this->uploadBaseDir.'/'.$relativeDir;
|
||||
|
||||
// Nom de stockage genere aleatoirement : evite les collisions et toute
|
||||
// injection via le nom client. Extension deduite du MIME.
|
||||
$extension = $file->guessExtension() ?: 'bin';
|
||||
$storedName = bin2hex(random_bytes(16)).'.'.$extension;
|
||||
|
||||
// Le nom d origine est conserve uniquement comme metadonnee d affichage,
|
||||
// borne a la longueur de colonne (255).
|
||||
$originalFilename = mb_substr($file->getClientOriginalName(), 0, 255);
|
||||
|
||||
$file->move($targetDir, $storedName);
|
||||
|
||||
return new UploadedDocument(
|
||||
originalFilename: $originalFilename,
|
||||
storedPath: $relativeDir.'/'.$storedName,
|
||||
mimeType: $mimeType,
|
||||
sizeBytes: $size,
|
||||
checksum: $checksum,
|
||||
createdAt: $now,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime le fichier physique d'un document (compensation lorsque la
|
||||
* persistance echoue APRES l'ecriture disque). Best-effort : silencieux si
|
||||
* le fichier a deja disparu. Evite d'accumuler des binaires orphelins non
|
||||
* references en base.
|
||||
*/
|
||||
public function remove(UploadedDocument $document): void
|
||||
{
|
||||
$path = $this->uploadBaseDir.'/'.$document->getStoredPath();
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use App\Module\Core\Domain\Entity\Permission;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Module\Transport\Domain\Entity\QualimatCarrier;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use Doctrine\ORM\Mapping\Entity;
|
||||
@@ -61,6 +62,10 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
||||
* spec-back M1 § 2.6 + § 3.5.
|
||||
* - Country (ERP-116) : referentiel statique des pays (id/code/name/position),
|
||||
* seede par migration, lecture seule. Meme justification que Bank.
|
||||
* - QualimatCarrier (M4, ERP-39/155) : mapping ORM LECTURE SEULE sur la table
|
||||
* referentielle qualimat_carrier, alimentee/soft-deletee exclusivement par
|
||||
* la commande `app:qualimat:sync` (pas de tracabilite user-driven, pas
|
||||
* d'ecriture API). Meme justification que les referentiels ci-dessus.
|
||||
*
|
||||
* Les futurs referentiels statiques s'ajoutent ici avec une justification.
|
||||
*/
|
||||
@@ -75,6 +80,7 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
||||
PaymentType::class,
|
||||
Bank::class,
|
||||
Country::class,
|
||||
QualimatCarrier::class,
|
||||
];
|
||||
|
||||
public function testAllBusinessEntitiesImplementBothInterfaces(): void
|
||||
|
||||
@@ -56,8 +56,19 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
'SupplierAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
|
||||
// Idem cote prestataire (meme Regex CP — M3 Technique).
|
||||
'ProviderAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
|
||||
// Idem cote transporteur (meme Regex CP — M4 Transport).
|
||||
'CarrierAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
|
||||
// Le Choice {PROSPECT,DEPART,RENDU} borne les valeurs (<= 8 < 20).
|
||||
'SupplierAddress::addressType' => 'Choice {PROSPECT,DEPART,RENDU} borne deja les valeurs.',
|
||||
// Le Choice {QUALIMAT,GMP_PLUS,OVOCOM,COMPTE_PROPRE,AUTRE} borne les valeurs (<= 13 < 20).
|
||||
'Carrier::certificationType' => 'Choice des 5 certifications borne deja les valeurs.',
|
||||
// Le Choice {BENNE,FOND_MOUVANT} borne les valeurs (<= 12).
|
||||
'Carrier::containerType' => 'Choice {BENNE,FOND_MOUVANT} borne deja les valeurs.',
|
||||
// Colonnes enum du prix transporteur (M4) : le Choice borne deja les valeurs.
|
||||
'CarrierPrice::direction' => 'Choice {CLIENT,FOURNISSEUR} borne deja les valeurs.',
|
||||
'CarrierPrice::containerType' => 'Choice {BENNE,FOND_MOUVANT} borne deja les valeurs.',
|
||||
'CarrierPrice::pricingUnit' => 'Choice {FORFAIT,TONNE} borne deja les valeurs.',
|
||||
'CarrierPrice::priceState' => 'Choice {EN_COURS,VALIDE,NON_VALIDE} borne deja les valeurs.',
|
||||
// Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres.
|
||||
'Site::color' => 'Regex code hex #RRGGBB borne deja la longueur.',
|
||||
];
|
||||
@@ -103,7 +114,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
}
|
||||
|
||||
/** @var Constraint $constraint */
|
||||
$constraint = $attribute->newInstance();
|
||||
$constraint = $attribute->newInstance();
|
||||
$messageProps = $this->messagePropertiesFor($constraint);
|
||||
|
||||
self::assertNotNull(
|
||||
@@ -174,6 +185,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
foreach ($constraints as $c) {
|
||||
if ($c instanceof Assert\Length) {
|
||||
$length = $c->max;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -245,7 +257,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
* Liste des proprietes de message a verifier pour une contrainte donnee, ou
|
||||
* null si la contrainte n'est pas geree (le test echoue alors explicitement).
|
||||
*
|
||||
* @return list<string>|null
|
||||
* @return null|list<string>
|
||||
*/
|
||||
private function messagePropertiesFor(Constraint $constraint): ?array
|
||||
{
|
||||
@@ -319,7 +331,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Constraint> $constraints
|
||||
* @param list<Constraint> $constraints
|
||||
* @param list<class-string<Constraint>> $classes
|
||||
*/
|
||||
private function hasAnyConstraint(array $constraints, array $classes): bool
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use App\Module\Commercial\Domain\Entity\SupplierAddress;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Entity\CarrierAddress;
|
||||
use App\Module\Transport\Domain\Entity\CarrierContact;
|
||||
use App\Module\Transport\Domain\Entity\CarrierPrice;
|
||||
use App\Module\Transport\Domain\Entity\QualimatCarrier;
|
||||
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Base des tests fonctionnels du repertoire transporteurs (M4). Apporte les
|
||||
* factories de seed direct (sans passer par l'API : le flux d'ecriture arrive
|
||||
* au WT4) pour les tests de lecture / serialisation / contrat (DoD § 4.0.bis).
|
||||
*
|
||||
* Donnees (RETEX M1) : chaque test seede ses transporteurs ; le tearDown les
|
||||
* purge (cascade BDD sur les sous-collections) ainsi que les lignes
|
||||
* qualimat_carrier de test (prefixe SIRET dedie).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class AbstractCarrierApiTestCase extends AbstractApiTestCase
|
||||
{
|
||||
protected const string LD = 'application/ld+json';
|
||||
protected const string MERGE = 'application/merge-patch+json';
|
||||
|
||||
/** Prefixe SIRET des lignes qualimat_carrier seedees par les tests (purge ciblee). */
|
||||
private const string TEST_SIRET_PREFIX = 'TESTQ';
|
||||
|
||||
/** Prefixe des Client/Supplier de test (cross-module Prix) — purge ciblee. */
|
||||
private const string TEST_REF_PREFIX = 'TESTCARRIERREF';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
// Carrier d'abord : ON DELETE CASCADE purge carrier_price (FK RESTRICT vers
|
||||
// client/supplier), liberant les Client/Supplier de test pour leur purge.
|
||||
$em->createQuery('DELETE FROM '.Carrier::class)->execute();
|
||||
$em->createQuery('DELETE FROM '.ClientEntity::class.' c WHERE c.companyName LIKE :p')
|
||||
->setParameter('p', self::TEST_REF_PREFIX.'%')->execute()
|
||||
;
|
||||
$em->createQuery('DELETE FROM '.Supplier::class.' s WHERE s.companyName LIKE :p')
|
||||
->setParameter('p', self::TEST_REF_PREFIX.'%')->execute()
|
||||
;
|
||||
// qualimat_carrier : insere en DBAL brut (entite lecture seule) -> purge DBAL.
|
||||
$em->getConnection()->executeStatement(
|
||||
'DELETE FROM qualimat_carrier WHERE siret LIKE :p',
|
||||
['p' => self::TEST_SIRET_PREFIX.'%'],
|
||||
);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
protected function createAdminClient(): Client
|
||||
{
|
||||
return $this->authenticatedClient('admin', 'admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Garde-fou ERP-101 : verifie qu'une reponse 422 porte une violation sur le
|
||||
* `propertyPath` attendu (et pas seulement le bon code HTTP). Sans cette
|
||||
* assertion, une 422 venue d'une AUTRE cause (autre champ manquant, IRI 404)
|
||||
* ferait passer le test au vert sans prouver le mapping inline par champ.
|
||||
*
|
||||
* Mutualise dans la base (au lieu d'un duplicata par fichier) pour que toute
|
||||
* la stack d'ecriture (formulaire principal + sous-ressources) l'utilise.
|
||||
*/
|
||||
protected static function assertViolationOnPath(object $response, string $path): void
|
||||
{
|
||||
/** @var ResponseInterface $response */
|
||||
$paths = array_column($response->toArray(false)['violations'] ?? [], 'propertyPath');
|
||||
|
||||
self::assertContains(
|
||||
$path,
|
||||
$paths,
|
||||
sprintf('Aucune violation sur "%s" (paths: %s).', $path, implode(', ', $paths)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload minimal valide du formulaire principal (transporteur non-QUALIMAT,
|
||||
* non affrete) : nom + certification GMP_PLUS. Sert de base aux tests
|
||||
* d'ecriture / RBAC.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function validMainPayload(string $name): array
|
||||
{
|
||||
return [
|
||||
'name' => $name,
|
||||
'certificationType' => 'GMP_PLUS',
|
||||
'isChartered' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un transporteur minimal (nom en MAJUSCULES, comme le ferait le
|
||||
* futur Processor). Sert aux tests de liste / archivage.
|
||||
*/
|
||||
protected function seedCarrier(string $name, bool $isArchived = false): Carrier
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$carrier = new Carrier();
|
||||
$carrier->setName(mb_strtoupper($name, 'UTF-8'));
|
||||
$carrier->setCertificationType('GMP_PLUS');
|
||||
$carrier->setIsArchived($isArchived);
|
||||
if ($isArchived) {
|
||||
$carrier->setArchivedAt(new DateTimeImmutable());
|
||||
}
|
||||
$em->persist($carrier);
|
||||
$em->flush();
|
||||
|
||||
return $carrier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un transporteur COMPLET (sans passer par l'API) : lien QUALIMAT,
|
||||
* 1 adresse, 1 contact, et 2 prix couvrant les deux branches (CLIENT avec
|
||||
* client + adresse de livraison + site de depart ; FOURNISSEUR avec
|
||||
* fournisseur + adresse d'appro + site de livraison). Socle du contrat de
|
||||
* serialisation et de la DoD (§ 4.0.bis).
|
||||
*/
|
||||
protected function seedCompleteCarrier(string $name): Carrier
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
|
||||
$qualimat = $this->seedQualimatCarrier($name);
|
||||
|
||||
$carrier = new Carrier();
|
||||
$carrier->setName(mb_strtoupper($name.' '.$suffix, 'UTF-8'));
|
||||
$carrier->setQualimatCarrier($qualimat);
|
||||
$carrier->setCertificationType('QUALIMAT');
|
||||
$em->persist($carrier);
|
||||
|
||||
$address = new CarrierAddress();
|
||||
$address->setCarrier($carrier);
|
||||
$address->setPostalCode('86000');
|
||||
$address->setCity('Poitiers');
|
||||
$address->setStreet('12 rue des Acacias');
|
||||
$carrier->addAddress($address);
|
||||
$em->persist($address);
|
||||
|
||||
$contact = new CarrierContact();
|
||||
$contact->setCarrier($carrier);
|
||||
$contact->setFirstName('Marie');
|
||||
$contact->setLastName('Martin');
|
||||
$contact->setPhonePrimary('0612345678');
|
||||
$contact->setEmail('marie.martin@seed.test');
|
||||
$carrier->addContact($contact);
|
||||
$em->persist($contact);
|
||||
|
||||
// Refs cross-module : seedees localement (en test, les fixtures M1/M2 ne
|
||||
// sont pas chargees — seuls les sites le sont). Prouve l'embed via les
|
||||
// contrats Shared + resolve_target_entities (regle n°1).
|
||||
$site = $em->getRepository(Site::class)->findOneBy([]);
|
||||
self::assertNotNull($site, 'Un site fixture est requis (SitesFixtures).');
|
||||
|
||||
$clientAddress = $this->seedClientWithAddress($name.' '.$suffix);
|
||||
$supplierAddress = $this->seedSupplierWithAddress($name.' '.$suffix);
|
||||
|
||||
// Branche CLIENT (RG-4.10).
|
||||
$clientPrice = new CarrierPrice();
|
||||
$clientPrice->setCarrier($carrier);
|
||||
$clientPrice->setDirection('CLIENT');
|
||||
$clientPrice->setClient($clientAddress->getClient());
|
||||
$clientPrice->setClientDeliveryAddress($clientAddress);
|
||||
$clientPrice->setDepartureSite($site);
|
||||
$clientPrice->setContainerType('BENNE');
|
||||
$clientPrice->setPricingUnit('TONNE');
|
||||
$clientPrice->setPrice('42.50');
|
||||
$clientPrice->setPriceState('VALIDE');
|
||||
$carrier->addPrice($clientPrice);
|
||||
$em->persist($clientPrice);
|
||||
|
||||
// Branche FOURNISSEUR (RG-4.11).
|
||||
$supplierPrice = new CarrierPrice();
|
||||
$supplierPrice->setCarrier($carrier);
|
||||
$supplierPrice->setDirection('FOURNISSEUR');
|
||||
$supplierPrice->setSupplier($supplierAddress->getSupplier());
|
||||
$supplierPrice->setSupplierSupplyAddress($supplierAddress);
|
||||
$supplierPrice->setDeliverySite($site);
|
||||
$supplierPrice->setContainerType('FOND_MOUVANT');
|
||||
$supplierPrice->setPricingUnit('FORFAIT');
|
||||
$supplierPrice->setPrice('320.00');
|
||||
$supplierPrice->setPriceState('EN_COURS');
|
||||
$carrier->addPrice($supplierPrice);
|
||||
$em->persist($supplierPrice);
|
||||
|
||||
$em->flush();
|
||||
|
||||
return $carrier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un Client minimal (companyName prefixe pour la purge) + une adresse
|
||||
* de livraison valide (CHECKs client_address respectes). Retourne l'adresse.
|
||||
*/
|
||||
protected function seedClientWithAddress(string $label): ClientAddress
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
|
||||
$client = new ClientEntity();
|
||||
$client->setCompanyName(mb_strtoupper(self::TEST_REF_PREFIX.' CLI '.$label.' '.$suffix, 'UTF-8'));
|
||||
$em->persist($client);
|
||||
|
||||
$address = new ClientAddress();
|
||||
$address->setClient($client);
|
||||
// Adresse de livraison : is_delivery=true, is_prospect=false, is_billing=false
|
||||
// -> satisfait chk_client_address_prospect_exclusive + chk_client_address_billing_email.
|
||||
$address->setIsDelivery(true);
|
||||
$address->setPostalCode('86000');
|
||||
$address->setCity('Poitiers');
|
||||
$address->setStreet('1 rue de la Livraison');
|
||||
$em->persist($address);
|
||||
|
||||
return $address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un Supplier minimal (companyName prefixe pour la purge) + une adresse
|
||||
* d'approvisionnement valide (address_type DEPART). Retourne l'adresse.
|
||||
*/
|
||||
protected function seedSupplierWithAddress(string $label): SupplierAddress
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
|
||||
$supplier = new Supplier();
|
||||
$supplier->setCompanyName(mb_strtoupper(self::TEST_REF_PREFIX.' FRN '.$label.' '.$suffix, 'UTF-8'));
|
||||
$em->persist($supplier);
|
||||
|
||||
$address = new SupplierAddress();
|
||||
$address->setSupplier($supplier);
|
||||
$address->setAddressType('DEPART');
|
||||
$address->setPostalCode('17000');
|
||||
$address->setCity('La Rochelle');
|
||||
$address->setStreet('2 quai de l Appro');
|
||||
$em->persist($address);
|
||||
|
||||
return $address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insere une ligne qualimat_carrier de test en DBAL brut (l'entite mappee est
|
||||
* en lecture seule) et retourne l'entite rechargee. SIRET prefixe pour la purge.
|
||||
*/
|
||||
protected function seedQualimatCarrier(string $name): QualimatCarrier
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$siret = self::TEST_SIRET_PREFIX.substr(bin2hex(random_bytes(6)), 0, 9);
|
||||
|
||||
$em->getConnection()->insert('qualimat_carrier', [
|
||||
'siret' => $siret,
|
||||
'name' => mb_strtoupper($name, 'UTF-8'),
|
||||
'address' => '12 rue des Acacias',
|
||||
'postal_code' => '86000',
|
||||
'city' => 'Poitiers',
|
||||
'status' => 'Valide',
|
||||
'validity_date' => '2027-12-31',
|
||||
'is_active' => 'true',
|
||||
'last_synced_at' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$qualimat = $em->getRepository(QualimatCarrier::class)->findOneBy(['siret' => $siret]);
|
||||
self::assertNotNull($qualimat, 'La ligne qualimat_carrier de test doit etre rechargeable.');
|
||||
|
||||
return $qualimat;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Module\Transport\Domain\Entity\CarrierAddress;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
/**
|
||||
* Sous-ressource Adresse d'un transporteur (spec-back M4 § 4.5, ERP-159).
|
||||
* POST /api/carriers/{id}/addresses, PATCH/DELETE /api/carrier_addresses/{id}.
|
||||
*
|
||||
* Contrat verifie :
|
||||
* - RG-4.06 : code postal hors ^[0-9]{4,5}$ -> 422 ;
|
||||
* - RG-4.05 : transporteur affrete + adresse incomplete -> 422 (par champ) ;
|
||||
* - RG-4.05 : transporteur affrete + adresse complete -> 201 ;
|
||||
* - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin
|
||||
// qu'en recette), requis pour les tests de permission (bureau/commerciale).
|
||||
self::bootKernel();
|
||||
$application = new Application(self::$kernel);
|
||||
$application->setAutoExit(false);
|
||||
$exit = $application->run(
|
||||
new ArrayInput([
|
||||
'command' => 'app:seed-rbac',
|
||||
'--with-demo-users' => true,
|
||||
'--password' => self::PWD,
|
||||
]),
|
||||
new NullOutput(),
|
||||
);
|
||||
self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).');
|
||||
|
||||
self::ensureKernelShutdown();
|
||||
}
|
||||
|
||||
public function testInvalidPostalCodeReturns422(): void
|
||||
{
|
||||
// Transporteur NON affrete : RG-4.05 ne s'applique pas, seule RG-4.06 joue.
|
||||
$carrier = $this->seedCarrierWithChartered('Cp Invalide', false);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '123'], // 3 chiffres -> Regex KO
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
// La 422 doit cibler le champ fautif (mapping inline ERP-101), pas juste le code HTTP.
|
||||
self::assertViolationOnPath($response, 'postalCode');
|
||||
}
|
||||
|
||||
public function testInconsistentPostalCodeAndCityIsAccepted(): void
|
||||
{
|
||||
// RG-4.06 : la validation serveur borne le FORMAT du code postal
|
||||
// (^[0-9]{4,5}$) mais ne controle PAS la coherence CP <-> ville (deleguee
|
||||
// a l'autocomplete BAN cote front). Un CP valide avec une ville qui ne lui
|
||||
// correspond pas est donc accepte (201).
|
||||
$carrier = $this->seedCarrierWithChartered('Cp Ville Incoherents', false);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86000', // Poitiers
|
||||
'city' => 'Marseille', // incoherent, mais non controle
|
||||
'street' => '1 rue de la Coherence',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testCharteredCarrierIncompleteAddressReturns422(): void
|
||||
{
|
||||
// Transporteur affrete : RG-4.05 exige Pays/CP/Ville/Adresse. CP valide mais
|
||||
// ville + rue manquantes -> 422 conditionnelle (CarrierAddressProcessor).
|
||||
$carrier = $this->seedCarrierWithChartered('Affrete Incomplet', true);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '86000'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
// RG-4.05 mappe une violation PAR champ manquant (ville + rue ici) -> chaque
|
||||
// erreur s'affiche inline sous son champ (ERP-101).
|
||||
self::assertViolationOnPath($response, 'city');
|
||||
self::assertViolationOnPath($response, 'street');
|
||||
}
|
||||
|
||||
public function testCharteredCarrierCompleteAddressIsCreated(): void
|
||||
{
|
||||
$carrier = $this->seedCarrierWithChartered('Affrete Complet', true);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'country' => 'France',
|
||||
'postalCode' => '86000',
|
||||
'city' => 'Poitiers',
|
||||
'street' => '12 rue des Acacias',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testPostAddressOnUnknownCarrierReturns404(): void
|
||||
{
|
||||
// Sous-ressource en read:false : le parent introuvable n'est plus intercepte
|
||||
// en amont -> le processor doit lever un 404 explicite (sinon 500 au persist).
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/999999/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '86000', 'city' => 'Poitiers', 'street' => '1 rue X'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testPatchAndDeleteSucceedWithManage(): void
|
||||
{
|
||||
$address = $this->seedAddress('Patch Delete', false);
|
||||
$client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2)
|
||||
|
||||
// PATCH (manage) -> 200
|
||||
$client->request('PATCH', '/api/carrier_addresses/'.$address->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['city' => 'Lyon'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// DELETE (manage) -> 204
|
||||
$client->request('DELETE', '/api/carrier_addresses/'.$address->getId());
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
public function testWriteForbiddenWithoutManage(): void
|
||||
{
|
||||
$address = $this->seedAddress('Forbidden', false);
|
||||
$carrier = $address->getCarrier();
|
||||
self::assertNotNull($carrier);
|
||||
$client = $this->authenticatedClient('commerciale', self::PWD); // view seul
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '86000', 'city' => 'Poitiers', 'street' => '1 rue X'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('PATCH', '/api/carrier_addresses/'.$address->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['city' => 'Lyon'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('DELETE', '/api/carrier_addresses/'.$address->getId());
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un transporteur minimal en controlant le flag affrete (RG-4.05).
|
||||
*/
|
||||
private function seedCarrierWithChartered(string $name, bool $isChartered): Carrier
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$carrier = new Carrier();
|
||||
$carrier->setName(mb_strtoupper($name, 'UTF-8'));
|
||||
$carrier->setCertificationType('GMP_PLUS');
|
||||
$carrier->setIsChartered($isChartered);
|
||||
$em->persist($carrier);
|
||||
$em->flush();
|
||||
|
||||
return $carrier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un transporteur + une adresse rattachee (pour les tests PATCH/DELETE).
|
||||
*/
|
||||
private function seedAddress(string $name, bool $isChartered): CarrierAddress
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$carrier = $this->seedCarrierWithChartered($name, $isChartered);
|
||||
|
||||
$address = new CarrierAddress();
|
||||
$address->setCarrier($carrier);
|
||||
$address->setPostalCode('86000');
|
||||
$address->setCity('Poitiers');
|
||||
$address->setStreet('12 rue des Acacias');
|
||||
$carrier->addAddress($address);
|
||||
$em->persist($address);
|
||||
$em->flush();
|
||||
|
||||
return $address;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
/**
|
||||
* Archivage / restauration transporteur — trou 409 de restauration en conflit
|
||||
* d'unicite (M4, RG-4.14). Le nominal (archive pose archivedAt) et le 422
|
||||
* « archive + autre champ » sont couverts par CarrierWriteApiTest. Jumeau de
|
||||
* SupplierArchiveTest (M2).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierArchiveTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
/**
|
||||
* RG-4.14 : restaurer un transporteur archive dont le nom a ete repris par un
|
||||
* transporteur actif entre-temps doit echouer en 409 (index partiel
|
||||
* uq_carrier_name_active : un seul actif portant ce nom).
|
||||
*/
|
||||
public function testRestoreConflictReturns409(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$archived = $this->seedCarrier('Acme Conflict', true);
|
||||
$this->seedCarrier('Acme Conflict', false);
|
||||
|
||||
$client->request('PATCH', '/api/carriers/'.$archived->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => false],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Tests Audit du repertoire transporteurs (M4, spec § 6). Couvre :
|
||||
* - POST / PATCH / archivage -> ligne audit_log entity_type='transport.Carrier'
|
||||
* avec l'action et le diff attendus ;
|
||||
* - le diff d'archivage trace bien le champ `isArchived` (RG-4.14).
|
||||
*
|
||||
* Jumeau de {@see \App\Tests\Module\Commercial\Api\SupplierAuditTest}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierAuditTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
private const string CARRIER_TYPE = 'transport.Carrier';
|
||||
|
||||
private ?Connection $auditConnection = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
self::bootKernel();
|
||||
|
||||
/** @var Connection $conn */
|
||||
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
|
||||
$this->auditConnection = $conn;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (null !== $this->auditConnection) {
|
||||
$this->auditConnection->close();
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testPostCarrierIsAudited(): void
|
||||
{
|
||||
$admin = $this->createAdminClient();
|
||||
|
||||
$created = $admin->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Audit Created Co'),
|
||||
])->toArray();
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
self::assertGreaterThanOrEqual(
|
||||
1,
|
||||
$this->countAudit(self::CARRIER_TYPE, (string) $created['id'], 'create'),
|
||||
'Un audit_log "create" doit etre genere pour le transporteur.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testPatchCarrierIsAudited(): void
|
||||
{
|
||||
$admin = $this->createAdminClient();
|
||||
$seed = $this->seedCarrier('Audit Patch Co');
|
||||
|
||||
$admin->request('PATCH', '/api/carriers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['name' => 'Audit Patch Renamed'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
self::assertGreaterThanOrEqual(
|
||||
1,
|
||||
$this->countAudit(self::CARRIER_TYPE, (string) $seed->getId(), 'update'),
|
||||
'Un audit_log "update" doit etre genere pour le PATCH.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testArchiveCarrierIsAudited(): void
|
||||
{
|
||||
$admin = $this->createAdminClient();
|
||||
$seed = $this->seedCarrier('Audit Archive Co');
|
||||
|
||||
$admin->request('PATCH', '/api/carriers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
$rows = $this->auditConnection->fetchAllAssociative(
|
||||
'SELECT changes FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action ORDER BY performed_at DESC',
|
||||
['type' => self::CARRIER_TYPE, 'id' => (string) $seed->getId(), 'action' => 'update'],
|
||||
);
|
||||
self::assertGreaterThanOrEqual(1, count($rows));
|
||||
|
||||
/** @var array<string, mixed> $changes */
|
||||
$changes = json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR);
|
||||
self::assertArrayHasKey('isArchived', $changes, 'Le diff d\'archivage doit tracer isArchived (RG-4.14).');
|
||||
}
|
||||
|
||||
private function countAudit(string $type, string $id, string $action): int
|
||||
{
|
||||
return (int) $this->auditConnection->fetchOne(
|
||||
'SELECT COUNT(*) FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action',
|
||||
['type' => $type, 'id' => $id, 'action' => $action],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
|
||||
use App\Module\Transport\Domain\Entity\CarrierContact;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
/**
|
||||
* Sous-ressource Contact d'un transporteur (spec-back M4 § 4.5, ERP-160).
|
||||
* POST /api/carriers/{id}/contacts, PATCH/DELETE /api/carrier_contacts/{id}.
|
||||
*
|
||||
* Contrat verifie :
|
||||
* - RG-4.08 : contact totalement vide -> 422 (au moins 1 champ requis) ;
|
||||
* - RG-4.08 : 1 seul champ rempli -> 201 ;
|
||||
* - RG-4.08 : 3 telephones (tableau `phones`) -> 422 (max 2) ;
|
||||
* - mapping `phones[]` -> phonePrimary / phoneSecondary + normalisation (RG-4.13) ;
|
||||
* - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierContactApiTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin
|
||||
// qu'en recette), requis pour les tests de permission (bureau/commerciale).
|
||||
self::bootKernel();
|
||||
$application = new Application(self::$kernel);
|
||||
$application->setAutoExit(false);
|
||||
$exit = $application->run(
|
||||
new ArrayInput([
|
||||
'command' => 'app:seed-rbac',
|
||||
'--with-demo-users' => true,
|
||||
'--password' => self::PWD,
|
||||
]),
|
||||
new NullOutput(),
|
||||
);
|
||||
self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).');
|
||||
|
||||
self::ensureKernelShutdown();
|
||||
}
|
||||
|
||||
public function testEmptyContactReturns422(): void
|
||||
{
|
||||
// RG-4.08 : aucun champ rempli -> 422 (garde Processor, double du CHECK BDD).
|
||||
$carrier = $this->seedCarrier('Contact Vide');
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
// RG-4.08 : la violation est rattachee a `firstName` (mapping inline ERP-101).
|
||||
self::assertViolationOnPath($response, 'firstName');
|
||||
}
|
||||
|
||||
public function testSingleFieldContactIsCreated(): void
|
||||
{
|
||||
// RG-4.08 : un seul champ suffit a valider le bloc.
|
||||
$carrier = $this->seedCarrier('Contact Mono');
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['lastName' => 'martin'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// RG-4.13 : nom capitalise serveur.
|
||||
self::assertJsonContains(['lastName' => 'Martin']);
|
||||
}
|
||||
|
||||
public function testThirdPhoneReturns422(): void
|
||||
{
|
||||
// RG-4.08 : max 2 telephones. Le contrat d'ecriture accepte un tableau
|
||||
// `phones` (liste dynamique cote front « x1, +1 possible, max 2 ») ; un 3e
|
||||
// numero -> 422 rattachee au champ `phones`.
|
||||
$carrier = $this->seedCarrier('Contact Trois Tel');
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'firstName' => 'Jean',
|
||||
'phones' => ['0611111111', '0622222222', '0633333333'],
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
// Le max-2 cible le champ virtuel `phones` (mapping inline ERP-101).
|
||||
self::assertViolationOnPath($response, 'phones');
|
||||
}
|
||||
|
||||
public function testInvalidEmailReturns422(): void
|
||||
{
|
||||
// L'email du contact porte un Assert\Email (nouvelle contrainte M4) : une
|
||||
// adresse mal formee -> 422 ciblee sur `email`.
|
||||
$carrier = $this->seedCarrier('Contact Email Invalide');
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['lastName' => 'Durand', 'email' => 'pas-un-email'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertViolationOnPath($response, 'email');
|
||||
}
|
||||
|
||||
public function testPostContactOnUnknownCarrierReturns404(): void
|
||||
{
|
||||
// Parent introuvable (read:false) -> 404 explicite du processor.
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/999999/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['lastName' => 'Martin'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testPhonesAreMappedAndNormalized(): void
|
||||
{
|
||||
// Mapping `phones[0]` -> phonePrimary, `phones[1]` -> phoneSecondary +
|
||||
// normalisation RG-4.13 (chiffres uniquement).
|
||||
$carrier = $this->seedCarrier('Contact Deux Tel');
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'lastName' => 'Dupont',
|
||||
'phones' => ['06.11.11.11.11', '06 22 22 22 22'],
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertJsonContains([
|
||||
'phonePrimary' => '0611111111',
|
||||
'phoneSecondary' => '0622222222',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testPatchAndDeleteSucceedWithManage(): void
|
||||
{
|
||||
$contact = $this->seedContact('Patch Delete');
|
||||
$client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2)
|
||||
|
||||
// PATCH (manage) -> 200
|
||||
$client->request('PATCH', '/api/carrier_contacts/'.$contact->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['jobTitle' => 'Directeur'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// DELETE (manage) -> 204
|
||||
$client->request('DELETE', '/api/carrier_contacts/'.$contact->getId());
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
public function testWriteForbiddenWithoutManage(): void
|
||||
{
|
||||
$contact = $this->seedContact('Forbidden');
|
||||
$carrier = $contact->getCarrier();
|
||||
self::assertNotNull($carrier);
|
||||
$client = $this->authenticatedClient('commerciale', self::PWD); // view seul
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['lastName' => 'Bernard'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('PATCH', '/api/carrier_contacts/'.$contact->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['jobTitle' => 'Chef'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('DELETE', '/api/carrier_contacts/'.$contact->getId());
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un transporteur + un contact rattache (pour les tests PATCH/DELETE).
|
||||
*/
|
||||
private function seedContact(string $name): CarrierContact
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$carrier = $this->seedCarrier($name);
|
||||
|
||||
$contact = new CarrierContact();
|
||||
$contact->setCarrier($carrier);
|
||||
$contact->setLastName('Martin');
|
||||
$contact->setPhonePrimary('0612345678');
|
||||
$carrier->addContact($contact);
|
||||
$em->persist($contact);
|
||||
$em->flush();
|
||||
|
||||
return $contact;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'export XLSX du repertoire transporteurs (M4, § 4.6).
|
||||
* Jumeau du {@see \App\Tests\Module\Commercial\Api\SupplierExportControllerTest}.
|
||||
*
|
||||
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes), exclusion
|
||||
* des archives par defaut, respect du filtre ?search, peuplement des colonnes
|
||||
* QUALIMAT (statut + date de validite, RG-4.04), 403 sans transport.carriers.view,
|
||||
* 401 anonyme.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierExportControllerTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
private const string EXPORT_URL = '/api/carriers/export.xlsx';
|
||||
|
||||
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedCarrier('Export Alpha');
|
||||
|
||||
$response = $client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$headers = $response->getHeaders(false);
|
||||
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
|
||||
|
||||
$disposition = $headers['content-disposition'][0] ?? '';
|
||||
self::assertStringContainsString('attachment; filename="repertoire-transporteurs-', $disposition);
|
||||
self::assertMatchesRegularExpression(
|
||||
'/filename="repertoire-transporteurs-\d{8}\.xlsx"/',
|
||||
$disposition,
|
||||
);
|
||||
|
||||
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
|
||||
$headers = $this->gridFromResponse($response->getContent())[0];
|
||||
self::assertSame('Nom', $headers[0]);
|
||||
self::assertContains('Certification', $headers);
|
||||
self::assertContains('Statut QUALIMAT', $headers);
|
||||
self::assertContains('Date de validité', $headers);
|
||||
self::assertContains('Affrété', $headers);
|
||||
self::assertContains('Volume m³', $headers);
|
||||
self::assertContains('Date de création', $headers);
|
||||
}
|
||||
|
||||
public function testExportExcludesArchivedByDefault(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedCarrier('Active One');
|
||||
$this->seedCarrier('Archived One', true);
|
||||
|
||||
$names = $this->carrierNames($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('ACTIVE ONE', $names);
|
||||
self::assertNotContains('ARCHIVED ONE', $names);
|
||||
}
|
||||
|
||||
public function testExportRespectsSearchFilter(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedCarrier('Searchable Alpha');
|
||||
$this->seedCarrier('Other Beta');
|
||||
|
||||
$names = $this->carrierNames(
|
||||
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
|
||||
);
|
||||
|
||||
self::assertContains('SEARCHABLE ALPHA', $names);
|
||||
self::assertNotContains('OTHER BETA', $names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes « Statut QUALIMAT » et « Date de validite » : alimentees par le
|
||||
* referentiel QUALIMAT lie (RG-4.04). Un transporteur complet seede un lien
|
||||
* QUALIMAT (statut « Valide », validite 31/12/2027).
|
||||
*/
|
||||
public function testExportPopulatesQualimatColumns(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedCompleteCarrier('Grelillier');
|
||||
|
||||
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
|
||||
|
||||
self::assertStringContainsString('QUALIMAT', $flat);
|
||||
self::assertStringContainsString('Valide', $flat);
|
||||
self::assertStringContainsString('31/12/2027', $flat);
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutCarriersViewPermission(): void
|
||||
{
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testUnauthorizedWhenAnonymous(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
|
||||
*
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
private function gridFromResponse(string $binary): array
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_carrier_export_test_');
|
||||
self::assertIsString($tmp);
|
||||
file_put_contents($tmp, $binary);
|
||||
|
||||
try {
|
||||
return IOFactory::load($tmp)->getActiveSheet()->toArray();
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait la colonne « Nom » (1re colonne) des lignes de donnees.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function carrierNames(string $binary): array
|
||||
{
|
||||
$rows = array_slice($this->gridFromResponse($binary), 1); // saute l'en-tete
|
||||
|
||||
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplatit toute la grille en une chaine, pour les assertions de presence.
|
||||
*
|
||||
* @param array<int, array<int, mixed>> $grid
|
||||
*/
|
||||
private function flatten(array $grid): string
|
||||
{
|
||||
return implode('|', array_map(
|
||||
static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)),
|
||||
$grid,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de la liste transporteurs (M4, spec § 4.1 + RG-4.14 + regle
|
||||
* ABSOLUE n°13) : tri name ASC, echappatoire ?pagination=false (selects), et
|
||||
* ANTI N+1 (le nombre de requetes SQL de la liste ne croit pas avec le nombre de
|
||||
* lignes — fetch-join qualimatCarrier batche, § 2.11). L'exclusion des archives
|
||||
* et la forme de l'enveloppe Hydra sont couvertes par
|
||||
* {@see CarrierSerializationContractTest::testCollectionEnvelopeShapeAndArchivedExcluded}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierListTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
public function testListIsSortedByNameAsc(): void
|
||||
{
|
||||
$http = $this->createAdminClient();
|
||||
$token = $this->token();
|
||||
|
||||
// Inseres dans le desordre ; le tri par defaut doit remonter ALPHA avant ZETA.
|
||||
$this->seedCarrier($token.' Zeta');
|
||||
$this->seedCarrier($token.' Alpha');
|
||||
|
||||
$names = array_map(
|
||||
static fn (array $m): string => (string) $m['name'],
|
||||
$http->request('GET', '/api/carriers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray()['member'],
|
||||
);
|
||||
|
||||
self::assertCount(2, $names);
|
||||
self::assertStringContainsString('ALPHA', $names[0], 'Tri name ASC (spec § 4.1).');
|
||||
self::assertStringContainsString('ZETA', $names[1]);
|
||||
}
|
||||
|
||||
public function testPaginationDisabledReturnsFullCollection(): void
|
||||
{
|
||||
$http = $this->createAdminClient();
|
||||
$token = $this->token();
|
||||
|
||||
for ($i = 0; $i < 3; ++$i) {
|
||||
$this->seedCarrier($token.' Item'.$i);
|
||||
}
|
||||
|
||||
// ?pagination=false : echappatoire pour alimenter un <select> (regle n°13).
|
||||
$data = $http->request('GET', '/api/carriers?search='.$token.'&pagination=false', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayHasKey('member', $data);
|
||||
self::assertCount(3, $data['member']);
|
||||
}
|
||||
|
||||
public function testAnonymousRequestReturns401(): void
|
||||
{
|
||||
// La collection est gatee par is_granted('transport.carriers.view') : un appel
|
||||
// NON authentifie doit recevoir 401 (spec § 4.1 liste 401 ET 403 ; jusqu'ici
|
||||
// seuls les exports couvraient le 401).
|
||||
$http = self::createClient();
|
||||
|
||||
$http->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testCertificationTypeFilterRestrictsResults(): void
|
||||
{
|
||||
// Filtre ?certificationType= (repetable, livre cote repo/provider mais
|
||||
// jusqu'ici non exerce en collection) : seul le transporteur OVOCOM remonte.
|
||||
$http = $this->createAdminClient();
|
||||
$token = $this->token();
|
||||
|
||||
$this->seedCarrier($token.' Gmp'); // GMP_PLUS (defaut seedCarrier)
|
||||
$ovocom = $this->seedCarrier($token.' Ovo');
|
||||
$ovocom->setCertificationType('OVOCOM');
|
||||
$this->getEm()->flush();
|
||||
|
||||
$data = $http->request(
|
||||
'GET',
|
||||
'/api/carriers?search='.$token.'&certificationType=OVOCOM',
|
||||
['headers' => ['Accept' => self::LD]],
|
||||
)->toArray();
|
||||
|
||||
self::assertCount(1, $data['member'], 'Seul le transporteur OVOCOM doit remonter.');
|
||||
self::assertStringContainsString('OVO', (string) $data['member'][0]['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anti N+1 (§ 2.11) : le nombre de requetes SQL de la liste ne doit PAS croitre
|
||||
* avec le nombre de transporteurs. On mesure pour N=2 puis N=4 (chacun avec son
|
||||
* lien QUALIMAT embarque) et on exige un compte IDENTIQUE — preuve que le
|
||||
* fetch-join `qualimatCarrier` est batche et non par ligne.
|
||||
*/
|
||||
public function testListQueryCountDoesNotGrowWithRowCount(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$token = $this->token();
|
||||
|
||||
// Premiere mesure : 2 transporteurs complets (lien QUALIMAT embarque en liste).
|
||||
$this->seedCompleteCarrier($token.' A');
|
||||
$this->seedCompleteCarrier($token.' B');
|
||||
$countFor2 = $this->countListQueries($token);
|
||||
|
||||
// Seconde mesure : 2 de plus (4 au total, tous sur la meme page).
|
||||
$this->seedCompleteCarrier($token.' C');
|
||||
$this->seedCompleteCarrier($token.' D');
|
||||
$countFor4 = $this->countListQueries($token);
|
||||
|
||||
self::assertSame(
|
||||
$countFor2,
|
||||
$countFor4,
|
||||
sprintf('Anti N+1 : le nombre de requetes liste doit etre constant (%d pour 2, %d pour 4).', $countFor2, $countFor4),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte les requetes SQL emises par UN GET liste filtre, via le data holder de
|
||||
* debug Doctrine (actif en test grace a `profiling: true` dans la config test,
|
||||
* independamment d'APP_DEBUG — sinon le compte casse en CI). Le holder est remis
|
||||
* a zero juste avant la requete pour isoler ses requetes (hors login).
|
||||
*/
|
||||
private function countListQueries(string $token): int
|
||||
{
|
||||
$http = $this->createAdminClient();
|
||||
$holder = self::getContainer()->get('doctrine.debug_data_holder');
|
||||
$holder->reset();
|
||||
|
||||
$http->request('GET', '/api/carriers?search='.$token, ['headers' => ['Accept' => self::LD]]);
|
||||
|
||||
$data = $holder->getData();
|
||||
|
||||
return count($data['default'] ?? []);
|
||||
}
|
||||
|
||||
private function token(): string
|
||||
{
|
||||
return 'List'.substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Module\Transport\Domain\Entity\CarrierPrice;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
/**
|
||||
* Sous-ressource Prix d'un transporteur (spec-back M4 § 4.5, ERP-161).
|
||||
* POST /api/carriers/{id}/prices, PATCH/DELETE /api/carrier_prices/{id}.
|
||||
*
|
||||
* Contrat verifie (RG-4.09→4.11) :
|
||||
* - branche CLIENT incomplete -> 422 ;
|
||||
* - branche FOURNISSEUR incomplete -> 422 ;
|
||||
* - adresse de livraison etrangere au client -> 422 ;
|
||||
* - adresse d'appro etrangere au fournisseur -> 422 ;
|
||||
* - prix CLIENT / FOURNISSEUR complets -> 201 ;
|
||||
* - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierPriceApiTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin
|
||||
// qu'en recette), requis pour les tests de permission (bureau/commerciale).
|
||||
self::bootKernel();
|
||||
$application = new Application(self::$kernel);
|
||||
$application->setAutoExit(false);
|
||||
$exit = $application->run(
|
||||
new ArrayInput([
|
||||
'command' => 'app:seed-rbac',
|
||||
'--with-demo-users' => true,
|
||||
'--password' => self::PWD,
|
||||
]),
|
||||
new NullOutput(),
|
||||
);
|
||||
self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).');
|
||||
|
||||
self::ensureKernelShutdown();
|
||||
}
|
||||
|
||||
public function testIncompleteClientBranchReturns422(): void
|
||||
{
|
||||
// RG-4.10 : direction CLIENT sans client / adresse / site de depart -> 422.
|
||||
$carrier = $this->seedCarrier('Prix Client Incomplet');
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'direction' => 'CLIENT',
|
||||
'containerType' => 'BENNE',
|
||||
'pricingUnit' => 'TONNE',
|
||||
'price' => '42.50',
|
||||
'priceState' => 'VALIDE',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testIncompleteSupplierBranchReturns422(): void
|
||||
{
|
||||
// RG-4.11 : direction FOURNISSEUR sans fournisseur / adresse / site -> 422.
|
||||
$carrier = $this->seedCarrier('Prix Fournisseur Incomplet');
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'direction' => 'FOURNISSEUR',
|
||||
'containerType' => 'FOND_MOUVANT',
|
||||
'pricingUnit' => 'FORFAIT',
|
||||
'price' => '320.00',
|
||||
'priceState' => 'EN_COURS',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testForeignClientAddressReturns422(): void
|
||||
{
|
||||
// RG-4.10 : l'adresse de livraison doit appartenir au client choisi.
|
||||
$carrier = $this->seedCarrier('Prix Adresse Etrangere Client');
|
||||
$addrA = $this->seedClientWithAddress('Client A');
|
||||
$addrB = $this->seedClientWithAddress('Client B');
|
||||
$this->getEm()->flush();
|
||||
$siteId = $this->aSiteId();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'direction' => 'CLIENT',
|
||||
'client' => '/api/clients/'.$addrA->getClient()?->getId(),
|
||||
'clientDeliveryAddress' => '/api/client_addresses/'.$addrB->getId(), // adresse du client B
|
||||
'departureSite' => '/api/sites/'.$siteId,
|
||||
'containerType' => 'BENNE',
|
||||
'pricingUnit' => 'TONNE',
|
||||
'price' => '42.50',
|
||||
'priceState' => 'VALIDE',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
// Faux-vert evite : la 422 doit prouver l'integrite referentielle adresse<->tiers
|
||||
// (violation sur clientDeliveryAddress), pas une autre cause (RG-4.10, ERP-101).
|
||||
self::assertViolationOnPath($response, 'clientDeliveryAddress');
|
||||
}
|
||||
|
||||
public function testForeignSupplierAddressReturns422(): void
|
||||
{
|
||||
// RG-4.11 : l'adresse d'appro doit appartenir au fournisseur choisi.
|
||||
$carrier = $this->seedCarrier('Prix Adresse Etrangere Fournisseur');
|
||||
$addrA = $this->seedSupplierWithAddress('Fournisseur A');
|
||||
$addrB = $this->seedSupplierWithAddress('Fournisseur B');
|
||||
$this->getEm()->flush();
|
||||
$siteId = $this->aSiteId();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'direction' => 'FOURNISSEUR',
|
||||
'supplier' => '/api/suppliers/'.$addrA->getSupplier()?->getId(),
|
||||
'supplierSupplyAddress' => '/api/supplier_addresses/'.$addrB->getId(), // adresse du fournisseur B
|
||||
'deliverySite' => '/api/sites/'.$siteId,
|
||||
'containerType' => 'FOND_MOUVANT',
|
||||
'pricingUnit' => 'FORFAIT',
|
||||
'price' => '320.00',
|
||||
'priceState' => 'EN_COURS',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertViolationOnPath($response, 'supplierSupplyAddress');
|
||||
}
|
||||
|
||||
public function testValidClientPriceIsCreated(): void
|
||||
{
|
||||
$carrier = $this->seedCarrier('Prix Client Valide');
|
||||
$addr = $this->seedClientWithAddress('Client OK');
|
||||
$this->getEm()->flush();
|
||||
$siteId = $this->aSiteId();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'direction' => 'CLIENT',
|
||||
'client' => '/api/clients/'.$addr->getClient()?->getId(),
|
||||
'clientDeliveryAddress' => '/api/client_addresses/'.$addr->getId(),
|
||||
'departureSite' => '/api/sites/'.$siteId,
|
||||
'containerType' => 'BENNE',
|
||||
'pricingUnit' => 'TONNE',
|
||||
'price' => '42.50',
|
||||
'priceState' => 'VALIDE',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertJsonContains(['direction' => 'CLIENT', 'priceState' => 'VALIDE']);
|
||||
}
|
||||
|
||||
public function testValidSupplierPriceIsCreated(): void
|
||||
{
|
||||
$carrier = $this->seedCarrier('Prix Fournisseur Valide');
|
||||
$addr = $this->seedSupplierWithAddress('Fournisseur OK');
|
||||
$this->getEm()->flush();
|
||||
$siteId = $this->aSiteId();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'direction' => 'FOURNISSEUR',
|
||||
'supplier' => '/api/suppliers/'.$addr->getSupplier()?->getId(),
|
||||
'supplierSupplyAddress' => '/api/supplier_addresses/'.$addr->getId(),
|
||||
'deliverySite' => '/api/sites/'.$siteId,
|
||||
'containerType' => 'FOND_MOUVANT',
|
||||
'pricingUnit' => 'FORFAIT',
|
||||
'price' => '320.00',
|
||||
'priceState' => 'EN_COURS',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertJsonContains(['direction' => 'FOURNISSEUR', 'priceState' => 'EN_COURS']);
|
||||
}
|
||||
|
||||
public function testNegativePriceReturns422(): void
|
||||
{
|
||||
// Le prix porte un Assert\PositiveOrZero : une valeur negative -> 422 sur `price`
|
||||
// (la branche CLIENT est par ailleurs complete pour isoler la cause).
|
||||
$carrier = $this->seedCarrier('Prix Negatif');
|
||||
$addr = $this->seedClientWithAddress('Client Prix Negatif');
|
||||
$this->getEm()->flush();
|
||||
$siteId = $this->aSiteId();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'direction' => 'CLIENT',
|
||||
'client' => '/api/clients/'.$addr->getClient()?->getId(),
|
||||
'clientDeliveryAddress' => '/api/client_addresses/'.$addr->getId(),
|
||||
'departureSite' => '/api/sites/'.$siteId,
|
||||
'containerType' => 'BENNE',
|
||||
'pricingUnit' => 'TONNE',
|
||||
'price' => '-5.00',
|
||||
'priceState' => 'VALIDE',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertViolationOnPath($response, 'price');
|
||||
}
|
||||
|
||||
public function testPostPriceOnUnknownCarrierReturns404(): void
|
||||
{
|
||||
// Parent introuvable (read:false) -> 404 explicite du processor (linkParent
|
||||
// s'execute avant validateBranch). Le payload porte les scalaires NotBlank
|
||||
// (containerType/pricingUnit/price/priceState) pour passer la validation
|
||||
// d'entite et atteindre le processor, ou le 404 prime.
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/999999/prices', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'direction' => 'CLIENT',
|
||||
'containerType' => 'BENNE',
|
||||
'pricingUnit' => 'TONNE',
|
||||
'price' => '42.50',
|
||||
'priceState' => 'VALIDE',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testPatchAndDeleteSucceedWithManage(): void
|
||||
{
|
||||
$price = $this->seedClientPrice('Patch Delete');
|
||||
$client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2)
|
||||
|
||||
// PATCH (manage) -> 200
|
||||
$client->request('PATCH', '/api/carrier_prices/'.$price->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['priceState' => 'NON_VALIDE'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertJsonContains(['priceState' => 'NON_VALIDE']);
|
||||
|
||||
// DELETE (manage) -> 204
|
||||
$client->request('DELETE', '/api/carrier_prices/'.$price->getId());
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
public function testWriteForbiddenWithoutManage(): void
|
||||
{
|
||||
$price = $this->seedClientPrice('Forbidden');
|
||||
$carrier = $price->getCarrier();
|
||||
self::assertNotNull($carrier);
|
||||
$client = $this->authenticatedClient('commerciale', self::PWD); // view seul
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['direction' => 'CLIENT'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('PATCH', '/api/carrier_prices/'.$price->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['priceState' => 'VALIDE'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('DELETE', '/api/carrier_prices/'.$price->getId());
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/** Id d'un site fixture (adresse de depart / livraison des prix). */
|
||||
private function aSiteId(): int
|
||||
{
|
||||
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
|
||||
self::assertNotNull($site, 'Un site fixture est requis (SitesFixtures).');
|
||||
$id = $site->getId();
|
||||
self::assertNotNull($id);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un transporteur + un prix CLIENT complet rattache (pour les tests
|
||||
* PATCH / DELETE). Passe par l'EM directement (le flux d'ecriture est teste
|
||||
* via l'API ailleurs).
|
||||
*/
|
||||
private function seedClientPrice(string $name): CarrierPrice
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$carrier = $this->seedCarrier($name);
|
||||
|
||||
/** @var ClientAddress $addr */
|
||||
$addr = $this->seedClientWithAddress($name);
|
||||
|
||||
$price = new CarrierPrice();
|
||||
$price->setCarrier($carrier);
|
||||
$price->setDirection('CLIENT');
|
||||
$price->setClient($addr->getClient());
|
||||
$price->setClientDeliveryAddress($addr);
|
||||
$price->setDepartureSite($em->getRepository(Site::class)->findOneBy([]));
|
||||
$price->setContainerType('BENNE');
|
||||
$price->setPricingUnit('TONNE');
|
||||
$price->setPrice('42.50');
|
||||
$price->setPriceState('VALIDE');
|
||||
$carrier->addPrice($price);
|
||||
$em->persist($price);
|
||||
$em->flush();
|
||||
|
||||
return $price;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'export XLSX du tableau Prix d'un transporteur (M4,
|
||||
* § 4.6 / spec-front « Onglet Prix »).
|
||||
*
|
||||
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes), rendu des
|
||||
* lignes regroupees par type de contenant (Benne / Fond Mouvant) avec ventilation
|
||||
* Forfait/Tonne, libelles d'etat FR, points de depart/livraison cross-module,
|
||||
* 404 sur transporteur inconnu, 403 sans transport.carriers.view, 401 anonyme.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierPriceExportControllerTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
|
||||
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$carrier = $this->seedCompleteCarrier('Price Alpha');
|
||||
|
||||
$response = $client->request('GET', $this->exportUrl($carrier));
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$headers = $response->getHeaders(false);
|
||||
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
|
||||
|
||||
$disposition = $headers['content-disposition'][0] ?? '';
|
||||
self::assertStringContainsString('attachment; filename="prix-transporteur-', $disposition);
|
||||
self::assertMatchesRegularExpression(
|
||||
'/filename="prix-transporteur-\d+-\d{8}\.xlsx"/',
|
||||
$disposition,
|
||||
);
|
||||
|
||||
$headerRow = $this->gridFromResponse($response->getContent())[0];
|
||||
self::assertSame('Type de contenant', $headerRow[0]);
|
||||
self::assertContains('Transporteurs', $headerRow);
|
||||
self::assertContains('Adresse APRO ou Adresse Sites', $headerRow);
|
||||
self::assertContains('Adresse livraisons', $headerRow);
|
||||
self::assertContains('Forfait €', $headerRow);
|
||||
self::assertContains('Tonne €', $headerRow);
|
||||
self::assertContains('Indexation', $headerRow);
|
||||
self::assertContains('État du prix', $headerRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le transporteur complet seede 2 prix : une branche CLIENT (Benne / Tonne /
|
||||
* 42.50 / Valide) et une branche FOURNISSEUR (Fond Mouvant / Forfait / 320.00 /
|
||||
* En cours). On verifie le regroupement par contenant, la ventilation
|
||||
* Forfait/Tonne, les libelles d'etat FR et les points de depart/livraison
|
||||
* cross-module (le prix CLIENT livre chez le client, le prix FOURNISSEUR part
|
||||
* de l'adresse du fournisseur).
|
||||
*/
|
||||
public function testExportRendersGroupedPriceRows(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$carrier = $this->seedCompleteCarrier('Price Grouping');
|
||||
|
||||
$grid = $this->gridFromResponse($client->request('GET', $this->exportUrl($carrier))->getContent());
|
||||
|
||||
$benne = $this->rowForContainer($grid, 'Benne');
|
||||
self::assertNotNull($benne, 'Ligne « Benne » introuvable dans l\'export prix.');
|
||||
self::assertSame($carrier->getName(), $benne[1]);
|
||||
// Branche CLIENT : prix en Tonne (42.50 -> 42.5 apres typage numerique du
|
||||
// classeur), colonne Forfait vide, etat « Valide », livraison chez le client.
|
||||
self::assertEmpty($benne[4]);
|
||||
self::assertEqualsWithDelta(42.5, (float) $benne[5], 0.001);
|
||||
self::assertSame('Validé', $benne[7]);
|
||||
self::assertStringContainsString('TESTCARRIERREF CLI', (string) $benne[3]);
|
||||
|
||||
$fondMouvant = $this->rowForContainer($grid, 'Fond Mouvant');
|
||||
self::assertNotNull($fondMouvant, 'Ligne « Fond Mouvant » introuvable dans l\'export prix.');
|
||||
// Branche FOURNISSEUR : prix au Forfait (320.00 -> 320), colonne Tonne vide,
|
||||
// etat « En cours », depart depuis l'adresse du fournisseur (APRO).
|
||||
self::assertEqualsWithDelta(320.0, (float) $fondMouvant[4], 0.001);
|
||||
self::assertEmpty($fondMouvant[5]);
|
||||
self::assertSame('En cours', $fondMouvant[7]);
|
||||
self::assertStringContainsString('TESTCARRIERREF FRN', (string) $fondMouvant[2]);
|
||||
}
|
||||
|
||||
public function testNotFoundForUnknownCarrier(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('GET', '/api/carriers/99999999/prices/export.xlsx');
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutCarriersViewPermission(): void
|
||||
{
|
||||
$carrier = $this->seedCompleteCarrier('Price Forbidden');
|
||||
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$client->request('GET', $this->exportUrl($carrier));
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testUnauthorizedWhenAnonymous(): void
|
||||
{
|
||||
$carrier = $this->seedCompleteCarrier('Price Anonymous');
|
||||
|
||||
$client = self::createClient();
|
||||
$client->request('GET', $this->exportUrl($carrier));
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
private function exportUrl(Carrier $carrier): string
|
||||
{
|
||||
return sprintf('/api/carriers/%d/prices/export.xlsx', (int) $carrier->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
|
||||
*
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
private function gridFromResponse(string $binary): array
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_carrier_price_export_test_');
|
||||
self::assertIsString($tmp);
|
||||
file_put_contents($tmp, $binary);
|
||||
|
||||
try {
|
||||
return IOFactory::load($tmp)->getActiveSheet()->toArray();
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renvoie la 1re ligne de donnees dont la colonne « Type de contenant »
|
||||
* (1re colonne) vaut $container, ou null.
|
||||
*
|
||||
* @param array<int, array<int, mixed>> $grid
|
||||
*
|
||||
* @return null|array<int, mixed>
|
||||
*/
|
||||
private function rowForContainer(array $grid, string $container): ?array
|
||||
{
|
||||
foreach (array_slice($grid, 1) as $row) {
|
||||
if ((string) ($row[0] ?? '') === $container) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
/**
|
||||
* Matrice RBAC du repertoire transporteurs par role metier (spec-back M4 § 5.2 +
|
||||
* ERP-153/158). Valide 200/403 par verbe pour bureau / compta / commerciale /
|
||||
* usine ; l'archivage reste admin seul (gating CarrierProcessor, RG-4.14). Jumeau
|
||||
* de SupplierRBACMatrixTest (M2).
|
||||
*
|
||||
* Matrice § 5.2 — rappel :
|
||||
* - bureau : view + manage (PAS archive)
|
||||
* - commerciale : view seul (ni manage ni archive)
|
||||
* - compta : aucun acces (403 sur view ET manage)
|
||||
* - usine : aucun acces (403 partout)
|
||||
* - archive : admin seul
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierRBACMatrixTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Seed idempotent via la commande applicative (roles + matrice § 5.2 +
|
||||
// comptes demo) — meme chemin qu'en recette.
|
||||
self::bootKernel();
|
||||
$application = new Application(self::$kernel);
|
||||
$application->setAutoExit(false);
|
||||
$exit = $application->run(
|
||||
new ArrayInput([
|
||||
'command' => 'app:seed-rbac',
|
||||
'--with-demo-users' => true,
|
||||
'--password' => self::PWD,
|
||||
]),
|
||||
new NullOutput(),
|
||||
);
|
||||
self::assertSame(
|
||||
0,
|
||||
$exit,
|
||||
'app:seed-rbac a echoue : les permissions transport.carriers.* sont-elles synchronisees (app:sync-permissions) ?',
|
||||
);
|
||||
|
||||
self::ensureKernelShutdown();
|
||||
}
|
||||
|
||||
public function testUsineIsForbiddenEverywhere(): void
|
||||
{
|
||||
$seed = $this->seedCarrier('Usine Target');
|
||||
$client = $this->authAs('usine');
|
||||
|
||||
$client->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('GET', '/api/carriers/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Usine Post'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testComptaHasNoAccess(): void
|
||||
{
|
||||
$seed = $this->seedCarrier('Compta Target');
|
||||
$client = $this->authAs('compta');
|
||||
|
||||
// PAS view (matrice § 5.2 : Compta sans acces transporteurs).
|
||||
$client->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS manage : creation refusee.
|
||||
$client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Compta Post'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('PATCH', '/api/carriers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['name' => 'Renamed By Compta'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testBureauHasViewAndManageButNoArchive(): void
|
||||
{
|
||||
$seed = $this->seedCarrier('Bureau Target');
|
||||
$client = $this->authAs('bureau');
|
||||
|
||||
// view
|
||||
$client->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// manage : creation OK
|
||||
$client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Bureau Created'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// manage : edition formulaire principal OK
|
||||
$client->request('PATCH', '/api/carriers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['name' => 'Bureau Renamed'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// PAS archive : archivage refuse (RG-4.14, gating CarrierProcessor).
|
||||
$client->request('PATCH', '/api/carriers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testCommercialeHasViewOnly(): void
|
||||
{
|
||||
$seed = $this->seedCarrier('Commerciale Target');
|
||||
$client = $this->authAs('commerciale');
|
||||
|
||||
// view (consultation « Tout »)
|
||||
$client->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// PAS manage : creation refusee
|
||||
$client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Commerciale Post'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS manage : edition refusee
|
||||
$client->request('PATCH', '/api/carriers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['name' => 'Renamed By Commerciale'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
private function authAs(string $role): Client
|
||||
{
|
||||
return $this->authenticatedClient($role, self::PWD);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
/**
|
||||
* Tests du CONTRAT DE SERIALISATION du repertoire transporteurs (M4, spec-back
|
||||
* § 4.0 / § 4.0.bis). Jumeau de {@see \App\Tests\Module\Commercial\Api\SupplierSerializationContractTest}.
|
||||
* Reverifie sur le JSON REEL les pieges silencieux du M1 transposes au M4 :
|
||||
* - #1/#2 : relations embarquees en OBJET (pas IRI nu) — qualimatCarrier, et au
|
||||
* detail prices[].client / .supplier / .departureSite / .deliverySite.
|
||||
* - #3 : booleens isArchived / isChartered presents dans le JSON (Groups +
|
||||
* SerializedName sur le getter).
|
||||
* - enveloppe AP4 (member/totalItems/view sans prefixe hydra:) + exclusion des
|
||||
* archives par defaut, ?includeArchived=true les reintegre.
|
||||
*
|
||||
* REGLE D'OR : on asserte sur le CORPS JSON reel, jamais sur les annotations.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierSerializationContractTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
// === Enveloppe AP4 + exclusion des archives (§ 4.1) ===
|
||||
|
||||
public function testCollectionEnvelopeShapeAndArchivedExcluded(): void
|
||||
{
|
||||
$http = $this->createAdminClient();
|
||||
$token = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
|
||||
$this->seedCarrier($token.' Active');
|
||||
$this->seedCarrier($token.' Archived', true);
|
||||
|
||||
$default = $http->request('GET', '/api/carriers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayHasKey('member', $default);
|
||||
self::assertArrayHasKey('totalItems', $default);
|
||||
self::assertArrayNotHasKey('hydra:member', $default);
|
||||
self::assertArrayNotHasKey('hydra:totalItems', $default);
|
||||
self::assertSame(1, $default['totalItems'], 'Archive exclu du totalItems par defaut.');
|
||||
|
||||
$all = $http->request('GET', '/api/carriers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertSame(2, $all['totalItems']);
|
||||
|
||||
$paged = $http->request('GET', '/api/carriers?search='.$token.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertArrayHasKey('view', $paged);
|
||||
self::assertArrayNotHasKey('hydra:view', $paged);
|
||||
}
|
||||
|
||||
// === #3 — Booleens presents (isArchived) + embed qualimatCarrier en LISTE ===
|
||||
|
||||
public function testListExposesIsArchivedAndEmbeddedQualimat(): void
|
||||
{
|
||||
$token = 'List'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
$carrier = $this->seedCompleteCarrier($token);
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$list = $http->request('GET', '/api/carriers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
$row = $this->memberById($list, (int) $carrier->getId());
|
||||
self::assertNotNull($row, 'Le transporteur seede doit apparaitre dans la liste filtree.');
|
||||
|
||||
// Boolean trap (#3) : cle presente et typee bool.
|
||||
self::assertArrayHasKey('isArchived', $row);
|
||||
self::assertFalse($row['isArchived']);
|
||||
|
||||
// qualimatCarrier embarque en OBJET (statut + date de validite — RG-4.04),
|
||||
// pas un IRI nu (#1/#2).
|
||||
self::assertArrayHasKey('qualimatCarrier', $row);
|
||||
self::assertIsArray($row['qualimatCarrier'], 'qualimatCarrier doit etre un objet embarque, pas un IRI nu.');
|
||||
self::assertArrayHasKey('status', $row['qualimatCarrier']);
|
||||
self::assertArrayHasKey('validityDate', $row['qualimatCarrier']);
|
||||
|
||||
// updatedAt (default:read) expose pour la colonne « Derniere activite ».
|
||||
self::assertArrayHasKey('updatedAt', $row);
|
||||
}
|
||||
|
||||
// === Detail : sous-collections embarquees + booleens ===
|
||||
|
||||
public function testDetailEmbedsSubCollectionsAndBooleans(): void
|
||||
{
|
||||
$carrier = $this->seedCompleteCarrier('Detail Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/carriers/'.$carrier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayHasKey('isArchived', $data);
|
||||
self::assertArrayHasKey('isChartered', $data);
|
||||
self::assertFalse($data['isArchived']);
|
||||
|
||||
self::assertNotEmpty($data['addresses']);
|
||||
self::assertSame('Poitiers', $data['addresses'][0]['city']);
|
||||
|
||||
self::assertNotEmpty($data['contacts']);
|
||||
self::assertSame('Marie', $data['contacts'][0]['firstName']);
|
||||
|
||||
self::assertNotEmpty($data['prices']);
|
||||
self::assertGreaterThanOrEqual(2, count($data['prices']));
|
||||
}
|
||||
|
||||
// === #1/#2 — prices[] : client / supplier / sites embarques en OBJET ===
|
||||
|
||||
public function testPriceCrossModuleRelationsAreEmbeddedObjects(): void
|
||||
{
|
||||
$carrier = $this->seedCompleteCarrier('Price Embed Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/carriers/'.$carrier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
$byDirection = [];
|
||||
foreach ($data['prices'] as $price) {
|
||||
$byDirection[$price['direction']] = $price;
|
||||
}
|
||||
|
||||
self::assertArrayHasKey('CLIENT', $byDirection);
|
||||
self::assertArrayHasKey('FOURNISSEUR', $byDirection);
|
||||
|
||||
// Branche CLIENT : client + adresse + site de depart en OBJET (pas IRI).
|
||||
$clientPrice = $byDirection['CLIENT'];
|
||||
self::assertIsArray($clientPrice['client'], 'prices[].client doit etre un objet embarque (client:read), pas un IRI nu.');
|
||||
self::assertArrayHasKey('companyName', $clientPrice['client']);
|
||||
self::assertIsArray($clientPrice['clientDeliveryAddress']);
|
||||
self::assertArrayHasKey('city', $clientPrice['clientDeliveryAddress'], 'L\'adresse client doit embarquer ses champs (client_address:read).');
|
||||
self::assertIsArray($clientPrice['departureSite']);
|
||||
self::assertArrayHasKey('name', $clientPrice['departureSite']);
|
||||
|
||||
// Branche FOURNISSEUR : supplier + adresse + site de livraison en OBJET.
|
||||
$supplierPrice = $byDirection['FOURNISSEUR'];
|
||||
self::assertIsArray($supplierPrice['supplier'], 'prices[].supplier doit etre un objet embarque (supplier:read), pas un IRI nu.');
|
||||
self::assertArrayHasKey('companyName', $supplierPrice['supplier']);
|
||||
self::assertIsArray($supplierPrice['supplierSupplyAddress']);
|
||||
self::assertArrayHasKey('city', $supplierPrice['supplierSupplyAddress'], 'L\'adresse fournisseur doit embarquer ses champs (supplier_address:read).');
|
||||
self::assertIsArray($supplierPrice['deliverySite']);
|
||||
}
|
||||
|
||||
// === RBAC : 403 sans la permission view ===
|
||||
|
||||
public function testForbiddenWithoutViewPermission(): void
|
||||
{
|
||||
$carrier = $this->seedCarrier('Rbac Co');
|
||||
|
||||
// user-nothing : aucune permission transport.carriers.*.
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$http->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(403, $http->getResponse()->getStatusCode());
|
||||
|
||||
$http->request('GET', '/api/carriers/'.$carrier->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(403, $http->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* DoD (§ 4.0.bis) : capture des reponses JSON REELLES (liste + detail) pour
|
||||
* les coller dans la spec avant de lancer les tickets front. Le test asserte
|
||||
* la forme ; si CARRIER_DOD_DUMP est positionnee, ecrit les corps sous /tmp.
|
||||
*/
|
||||
public function testDodReferenceJsonShape(): void
|
||||
{
|
||||
$token = 'DoD'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
$carrier = $this->seedCompleteCarrier($token);
|
||||
$id = (int) $carrier->getId();
|
||||
|
||||
$admin = $this->createAdminClient();
|
||||
$list = $admin->request('GET', '/api/carriers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
$detail = $admin->request('GET', '/api/carriers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayHasKey('member', $list);
|
||||
self::assertArrayHasKey('qualimatCarrier', $detail);
|
||||
self::assertArrayHasKey('addresses', $detail);
|
||||
self::assertArrayHasKey('contacts', $detail);
|
||||
self::assertArrayHasKey('prices', $detail);
|
||||
|
||||
if (false !== getenv('CARRIER_DOD_DUMP')) {
|
||||
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
|
||||
file_put_contents('/tmp/carrier-dod-list.json', json_encode($list, $flags));
|
||||
file_put_contents('/tmp/carrier-dod-detail.json', json_encode($detail, $flags));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrouve un membre de la collection par son id (liste filtree).
|
||||
*
|
||||
* @param array<string, mixed> $collection
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function memberById(array $collection, int $id): ?array
|
||||
{
|
||||
foreach ($collection['member'] ?? [] as $member) {
|
||||
if (($member['id'] ?? null) === $id) {
|
||||
return $member;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
/**
|
||||
* Ecriture du formulaire principal transporteur (M4, WT4 — ERP-158). Couvre les
|
||||
* RG du CarrierProcessor + contraintes conditionnelles : RG-4.01 (QUALIMAT + cas
|
||||
* LIOT), RG-4.02 (AUTRE -> decharge), RG-4.03 (affrete -> indexation/benne/volume),
|
||||
* RG-4.12 (doublon de nom -> 409), RG-4.13 (normalisation), RG-4.14 (archivage +
|
||||
* mode strict). Jumeau des SupplierApiTest / SupplierPatchStrictTest (M2).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierWriteApiTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
/**
|
||||
* RG-4.01 : POST avec qualimatCarrier -> certificationType=QUALIMAT accepte et
|
||||
* FK persistee (verifiee au detail, qualimatCarrier embarque).
|
||||
*/
|
||||
public function testPostQualimatPersistsCertificationAndForeignKey(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$qualimat = $this->seedQualimatCarrier('Transports Grelillier');
|
||||
|
||||
$created = $client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'name' => 'Transports Grelillier',
|
||||
'qualimatCarrier' => '/api/qualimat_carriers/'.$qualimat->getId(),
|
||||
'certificationType' => 'QUALIMAT',
|
||||
'isChartered' => false,
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame('QUALIMAT', $created['certificationType']);
|
||||
|
||||
$detail = $client->request('GET', $created['@id'], ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertIsArray($detail['qualimatCarrier']);
|
||||
self::assertSame((int) $qualimat->getId(), (int) $detail['qualimatCarrier']['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.01 (cas LIOT) : nom = LIOT -> certificationType non requis (champ masque)
|
||||
* et liotPlates accepte (et normalise, RG-4.13).
|
||||
*/
|
||||
public function testPostLiotAcceptsPlatesWithoutCertification(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$created = $client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'name' => 'LIOT',
|
||||
'liotPlates' => 'ab-123-cd ; ef-456-gh',
|
||||
'isChartered' => false,
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertNull($created['certificationType']);
|
||||
self::assertSame('AB-123-CD; EF-456-GH', $created['liotPlates']);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.01 : hors cas LIOT, l'absence de certification est rejetee (422 cible
|
||||
* sur certificationType).
|
||||
*/
|
||||
public function testPostWithoutCertificationOutsideLiotIsRejected(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['name' => 'Sans Certif', 'isChartered' => false],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertViolationOnPath($response, 'certificationType');
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.02 : certificationType=AUTRE sans dischargeDocument -> 422 cible ; une
|
||||
* certification != AUTRE sans decharge passe (201).
|
||||
*/
|
||||
public function testAutreCertificationRequiresDischarge(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['name' => 'Sans Decharge', 'certificationType' => 'AUTRE', 'isChartered' => false],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertViolationOnPath($response, 'dischargeDocument');
|
||||
|
||||
// Certification != AUTRE : pas de decharge requise.
|
||||
$client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['name' => 'Avec GmpPlus', 'certificationType' => 'GMP_PLUS', 'isChartered' => false],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.03 : isChartered=true sans indexationRate / containerType / volumeM3 ->
|
||||
* 422 (violations ciblees) ; complet -> 201.
|
||||
*/
|
||||
public function testCharteredRequiresIndexationContainerAndVolume(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['name' => 'Affrete Incomplet', 'certificationType' => 'GMP_PLUS', 'isChartered' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertViolationOnPath($response, 'indexationRate');
|
||||
self::assertViolationOnPath($response, 'containerType');
|
||||
self::assertViolationOnPath($response, 'volumeM3');
|
||||
|
||||
$client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'name' => 'Affrete Complet',
|
||||
'certificationType' => 'GMP_PLUS',
|
||||
'isChartered' => true,
|
||||
'indexationRate' => '5.00',
|
||||
'containerType' => 'BENNE',
|
||||
'volumeM3' => '90.00',
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.12 : nom deja pris (parmi actifs) -> 409. Le meme nom redevient
|
||||
* disponible apres archivage de l'ancien -> 201.
|
||||
*/
|
||||
public function testDuplicateNameReturns409AndIsFreedAfterArchive(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$existing = $this->seedCarrier('Doublon Co');
|
||||
|
||||
$client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Doublon Co'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
|
||||
// Archivage de l'ancien -> le nom se libere (index partiel sur actifs).
|
||||
$client->request('PATCH', '/api/carriers/'.$existing->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
$client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Doublon Co'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.13 : le nom est persiste en MAJUSCULES (normalisation serveur).
|
||||
*/
|
||||
public function testNameIsUpperCasedOnPersist(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$created = $client->request('POST', '/api/carriers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('transports x'),
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame('TRANSPORTS X', $created['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.14 : PATCH isArchived=true par Admin -> 200 + archivedAt rempli ;
|
||||
* restauration -> archivedAt remis a null.
|
||||
*/
|
||||
public function testAdminArchiveSetsArchivedAtAndRestoreClearsIt(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$carrier = $this->seedCarrier('A Archiver');
|
||||
|
||||
$archived = $client->request('PATCH', '/api/carriers/'.$carrier->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
])->toArray();
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertTrue($archived['isArchived']);
|
||||
self::assertNotNull($archived['archivedAt']);
|
||||
|
||||
$restored = $client->request('PATCH', '/api/carriers/'.$carrier->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => false],
|
||||
])->toArray();
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertFalse($restored['isArchived']);
|
||||
self::assertNull($restored['archivedAt']);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.14 (mode strict) : une requete d'archivage ne peut modifier aucun autre
|
||||
* champ ecrivable -> 422.
|
||||
*/
|
||||
public function testArchiveRequestMixingOtherFieldIsRejected(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$carrier = $this->seedCarrier('Strict Co');
|
||||
|
||||
$client->request('PATCH', '/api/carriers/'.$carrier->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true, 'name' => 'Renamed While Archiving'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
/**
|
||||
* Endpoint de recherche du referentiel QUALIMAT (spec-back M4 § 4.7 / RG-4.01,
|
||||
* ERP-156). Saisie assistee du nom : GET /api/qualimat_carriers?search= .
|
||||
*
|
||||
* Contrat verifie :
|
||||
* - recherche fuzzy sur name (+ siret), SEULEMENT les lignes actives ;
|
||||
* - tri name ASC ;
|
||||
* - enveloppe Hydra paginee (member / totalItems / view — regle n°13) ;
|
||||
* - 403 sans la permission transport.carriers.view (compta/usine, matrice § 5.2).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class QualimatCarrierSearchTest extends AbstractCarrierApiTestCase
|
||||
{
|
||||
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
|
||||
|
||||
/** Prefixe SIRET dedie (purge par AbstractCarrierApiTestCase::tearDown). */
|
||||
private const string SIRET_PREFIX = 'TESTQ';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin
|
||||
// qu'en recette), requis pour le test de permission (usine sans acces).
|
||||
self::bootKernel();
|
||||
$application = new Application(self::$kernel);
|
||||
$application->setAutoExit(false);
|
||||
$exit = $application->run(
|
||||
new ArrayInput([
|
||||
'command' => 'app:seed-rbac',
|
||||
'--with-demo-users' => true,
|
||||
'--password' => self::PWD,
|
||||
]),
|
||||
new NullOutput(),
|
||||
);
|
||||
self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).');
|
||||
|
||||
self::ensureKernelShutdown();
|
||||
}
|
||||
|
||||
public function testSearchReturnsOnlyActiveOrderedByName(): void
|
||||
{
|
||||
// Marqueur unique partage par les 3 lignes : isole la recherche d'eventuelles
|
||||
// autres lignes du referentiel.
|
||||
$this->insertQualimat('QSEARCH GAMMA', true, 'A1');
|
||||
$this->insertQualimat('QSEARCH ALPHA', true, 'A2');
|
||||
$this->insertQualimat('QSEARCH BETA', false, 'A3'); // inactive -> exclue
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('GET', '/api/qualimat_carriers?search=qsearch', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$data = $client->getResponse()->toArray();
|
||||
$names = array_column($data['member'], 'name');
|
||||
|
||||
self::assertSame(2, $data['totalItems'], 'Seules les 2 lignes actives doivent remonter (BETA inactive exclue).');
|
||||
self::assertSame(['QSEARCH ALPHA', 'QSEARCH GAMMA'], $names, 'Tri name ASC, sans la ligne inactive.');
|
||||
}
|
||||
|
||||
public function testSearchMatchesSiret(): void
|
||||
{
|
||||
// Le nom ne porte pas le marqueur : la correspondance se fait via le siret.
|
||||
$this->insertQualimat('TRANSPORTEUR SANS MARQUEUR', true, 'SIRETHIT1');
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('GET', '/api/qualimat_carriers?search=testqsirethit1', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$data = $client->getResponse()->toArray();
|
||||
self::assertSame(1, $data['totalItems'], 'La recherche fuzzy doit aussi cibler le siret.');
|
||||
self::assertSame('TRANSPORTEUR SANS MARQUEUR', $data['member'][0]['name']);
|
||||
}
|
||||
|
||||
public function testCollectionExposesHydraPagination(): void
|
||||
{
|
||||
$this->insertQualimat('QPAGE UN', true, 'P1');
|
||||
$this->insertQualimat('QPAGE DEUX', true, 'P2');
|
||||
$this->insertQualimat('QPAGE TROIS', true, 'P3');
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('GET', '/api/qualimat_carriers?search=qpage&itemsPerPage=2', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$data = $client->getResponse()->toArray();
|
||||
self::assertArrayHasKey('totalItems', $data, 'La collection doit exposer totalItems.');
|
||||
self::assertArrayHasKey('view', $data, 'La collection doit exposer view quand totalItems > itemsPerPage.');
|
||||
self::assertIsArray($data['member']);
|
||||
self::assertSame(3, $data['totalItems']);
|
||||
self::assertCount(2, $data['member'], 'La page doit etre bornee a itemsPerPage=2.');
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutPermission(): void
|
||||
{
|
||||
// Usine : aucun acces transporteurs (matrice § 5.2) -> 403 sur la recherche.
|
||||
$client = $this->authenticatedClient('usine', self::PWD);
|
||||
$client->request('GET', '/api/qualimat_carriers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insere une ligne qualimat_carrier de test en DBAL brut (l'entite mappee est
|
||||
* en lecture seule). SIRET prefixe TESTQ pour la purge ciblee du tearDown.
|
||||
*/
|
||||
private function insertQualimat(string $name, bool $isActive, string $siretSuffix): void
|
||||
{
|
||||
$this->getEm()->getConnection()->insert('qualimat_carrier', [
|
||||
'siret' => self::SIRET_PREFIX.$siretSuffix,
|
||||
'name' => $name,
|
||||
'status' => 'Valide',
|
||||
'validity_date' => '2027-12-31',
|
||||
'is_active' => $isActive ? 'true' : 'false',
|
||||
'last_synced_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Application;
|
||||
|
||||
use App\Module\Transport\Application\Service\CarrierFieldNormalizer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Normalisation serveur des champs texte du repertoire transporteurs (RG-4.13 +
|
||||
* cas LIOT RG-4.01). Jumeau de SupplierFieldNormalizerTest (M2), enrichi de
|
||||
* normalizeLiotPlates.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CarrierFieldNormalizerTest extends TestCase
|
||||
{
|
||||
private CarrierFieldNormalizer $normalizer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->normalizer = new CarrierFieldNormalizer();
|
||||
}
|
||||
|
||||
public function testNameIsUpperCasedAndTrimmed(): void
|
||||
{
|
||||
self::assertSame('TRANSPORTS X', $this->normalizer->normalizeName(' transports x '));
|
||||
self::assertNull($this->normalizer->normalizeName(null));
|
||||
}
|
||||
|
||||
public function testPersonNameIsTitleCased(): void
|
||||
{
|
||||
self::assertSame('Jean Dupont', $this->normalizer->normalizePersonName('JEAN dupont'));
|
||||
self::assertNull($this->normalizer->normalizePersonName(' '));
|
||||
self::assertNull($this->normalizer->normalizePersonName(null));
|
||||
}
|
||||
|
||||
public function testEmailIsLowerCased(): void
|
||||
{
|
||||
self::assertSame('marie.martin@seed.test', $this->normalizer->normalizeEmail(' Marie.MARTIN@Seed.Test '));
|
||||
self::assertNull($this->normalizer->normalizeEmail(' '));
|
||||
self::assertNull($this->normalizer->normalizeEmail(null));
|
||||
}
|
||||
|
||||
public function testPhoneKeepsDigitsOnly(): void
|
||||
{
|
||||
self::assertSame('0612345678', $this->normalizer->normalizePhone('06.12.34.56.78'));
|
||||
self::assertSame('33612345678', $this->normalizer->normalizePhone('+33 6 12 34 56 78'));
|
||||
self::assertNull($this->normalizer->normalizePhone('sans chiffre'));
|
||||
self::assertNull($this->normalizer->normalizePhone(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.01 / RG-4.13 : la saisie « ; »-separee est decoupee, chaque plaque trim
|
||||
* + UPPER, segments vides ecartes, recomposee avec "; ".
|
||||
*/
|
||||
public function testLiotPlatesAreSplitTrimmedUpperedAndRejoined(): void
|
||||
{
|
||||
self::assertSame(
|
||||
'AB-123-CD; EF-456-GH',
|
||||
$this->normalizer->normalizeLiotPlates('ab-123-cd ; ef-456-gh'),
|
||||
);
|
||||
// Segments vides (« ;; » / fin de chaine) ecartes.
|
||||
self::assertSame('AB-123-CD', $this->normalizer->normalizeLiotPlates(' ab-123-cd ; ; '));
|
||||
self::assertNull($this->normalizer->normalizeLiotPlates(' ; ; '));
|
||||
self::assertNull($this->normalizer->normalizeLiotPlates(null));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user