Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4369c71706 | |||
| f6d39cb187 | |||
| 864bc7e8e8 | |||
| 726be37ccf | |||
| c09b3cda2b | |||
| 3b474f83f5 | |||
| c60daebf3e | |||
| 6dab7cfd17 | |||
| c1fcd9a7c8 | |||
| 18c88156e5 | |||
| c0fa00c9c5 | |||
| e688fe7e0b | |||
| 7d2812cea6 | |||
| daa8224b8b | |||
| 7012306a78 | |||
| 397fb22c62 | |||
| 13d4a08bc9 | |||
| aa23189fe1 | |||
| dc75945f3e | |||
| 2be9cd05d4 |
@@ -56,7 +56,10 @@ jobs:
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.4'
|
||||
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium
|
||||
# gd requis par phpoffice/phpspreadsheet (export XLSX). Doit etre explicite :
|
||||
# sinon `composer install` echoue sur la verification de plateforme des que
|
||||
# le runner ne fournit pas l'extension par defaut (ext-gd manquante).
|
||||
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium, gd
|
||||
coverage: none
|
||||
tools: composer:v2
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
use App\Module\Catalog\CatalogModule;
|
||||
use App\Module\Commercial\CommercialModule;
|
||||
use App\Module\Core\CoreModule;
|
||||
use App\Module\Logistique\LogistiqueModule;
|
||||
use App\Module\Sites\SitesModule;
|
||||
use App\Module\Technique\TechniqueModule;
|
||||
use App\Module\Transport\TransportModule;
|
||||
@@ -15,4 +16,5 @@ return [
|
||||
CatalogModule::class,
|
||||
TechniqueModule::class,
|
||||
TransportModule::class,
|
||||
LogistiqueModule::class,
|
||||
];
|
||||
|
||||
@@ -14,18 +14,28 @@ 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.
|
||||
# - `weighing_ticket_counter` / `weighbridge_dsd_counter` : compteurs
|
||||
# par site (numero de ticket de pesee RG-5.02 / DSD du pont RG-5.04,
|
||||
# M5 Logistique), incrementes en DBAL brut sous verrou `FOR UPDATE`
|
||||
# par l'allocateur — jamais mappes en ORM (cf. spec M5 § 2.5 / § 2.7).
|
||||
# Sans ce filtre, schema:update les considere comme "orphelines" et
|
||||
# genere un `DROP TABLE` qui casse la base de test apres chaque
|
||||
# `make test-db-setup` (la migration les a creees, schema:update les
|
||||
# supprime juste apres). Creation / suppression restent pilotees par
|
||||
# les migrations (audit_log : Version20260420202749 ; qualimat :
|
||||
# Version20260612150000 ; idtf : Version20260612160000).
|
||||
schema_filter: '~^(?!(?:audit_log|qualimat_carrier|qualimat_sync_log|idtf_product|idtf_sync_log)$).+~'
|
||||
# Version20260612150000 ; idtf : Version20260612160000 ; compteurs M5 :
|
||||
# Version20260617150000).
|
||||
schema_filter: '~^(?!(?:audit_log|qualimat_sync_log|idtf_product|idtf_sync_log|weighing_ticket_counter|weighbridge_dsd_counter)$).+~'
|
||||
audit:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
orm:
|
||||
@@ -49,6 +59,15 @@ 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).
|
||||
@@ -108,6 +127,28 @@ 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
|
||||
# Mapping inconditionnel du module Logistique (meme logique que Transport) :
|
||||
# la table weighing_ticket (tickets de pesee M5) creee par la migration
|
||||
# Version20260617150000 doit etre connue de l'ORM, sinon schema:update la
|
||||
# drope sur la base de test. L'activation fonctionnelle passe par
|
||||
# config/modules.php.
|
||||
Logistique:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Logistique/Domain/Entity'
|
||||
prefix: 'App\Module\Logistique\Domain\Entity'
|
||||
alias: Logistique
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
|
||||
@@ -78,6 +78,41 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Transport" (M4, ERP-153) : pole logistique, porte le repertoire
|
||||
// transporteurs. L'item est gate par `transport.carriers.view` ; la section
|
||||
// disparait automatiquement (SidebarProvider) si le module `transport` est
|
||||
// desactive ou si l'user n'a pas la permission (Compta / Usine).
|
||||
[
|
||||
'label' => 'sidebar.transport.section',
|
||||
'icon' => 'mdi:truck-outline',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.transport.carriers',
|
||||
'to' => '/carriers',
|
||||
'icon' => 'mdi:truck-outline',
|
||||
'module' => 'transport',
|
||||
'permission' => 'transport.carriers.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Logistique" (M5, ERP-181) : nouveau pole "operations physiques sur
|
||||
// site", distinct du repertoire Transport (M4). Porte le ticket de pesee au
|
||||
// pont bascule. L'item est gate par `logistique.weighing_tickets.view` ; la
|
||||
// section disparait automatiquement (SidebarProvider) si le module `logistique`
|
||||
// est desactive ou si l'user n'a pas la permission (Compta / Commerciale).
|
||||
[
|
||||
'label' => 'sidebar.logistique.section',
|
||||
'icon' => 'mdi:scale',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.logistique.weighing_tickets',
|
||||
'to' => '/weighing-tickets',
|
||||
'icon' => 'mdi:scale',
|
||||
'module' => 'logistique',
|
||||
'permission' => 'logistique.weighing_tickets.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Administration" : regroupe toutes les pages de configuration
|
||||
// applicative (RBAC, users, sites, audit log).
|
||||
//
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.128'
|
||||
app.version: '0.1.130'
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Prompt d'implémentation — M5 · ERP-181 (1.1) — Scaffolder le module Logistique + RBAC
|
||||
|
||||
Projet **Starseed** (modular monolith DDD). Tâche **back**. Lis d'abord `CLAUDE.md` + `.claude/rules/architecture.md` + `.claude/rules/backend.md`, puis la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 2.1, § 5).
|
||||
|
||||
## Mission
|
||||
Créer le **nouveau module `Logistique`** et poser son socle RBAC, **avant toute entité**. Aucun écran fonctionnel ici, juste le squelette + permissions + sidebar + 3 miroirs RBAC.
|
||||
|
||||
## Étapes
|
||||
1. Scaffolder via le skill projet **`create-module`** : `src/Module/Logistique/` avec `Domain/ Application/ Infrastructure/` et `LogistiqueModule.php` :
|
||||
- `const string ID = 'logistique'` ; `const string LABEL = 'Logistique'` ; `const bool REQUIRED = false`.
|
||||
- `permissions()` retourne :
|
||||
- `['code' => 'logistique.weighing_tickets.view', 'label' => 'Voir les tickets de pesée']`
|
||||
- `['code' => 'logistique.weighing_tickets.manage', 'label' => 'Créer / modifier les tickets de pesée']`
|
||||
2. Enregistrer `LogistiqueModule::class` dans `config/modules.php`.
|
||||
3. Créer le layer front minimal `frontend/modules/logistique/nuxt.config.ts` (kebab-case, auto-détecté).
|
||||
4. Ajouter à `config/sidebar.php` une section/item « Logistique » :
|
||||
```php
|
||||
['label' => 'sidebar.logistique.weighing_tickets', 'to' => '/weighing-tickets',
|
||||
'icon' => 'mdi-scale', 'module' => 'logistique', 'permission' => 'logistique.weighing_tickets.view'],
|
||||
```
|
||||
+ la clé i18n `sidebar.logistique.*` dans `frontend/i18n/locales/fr.json`.
|
||||
5. **Règle ABSOLUE n°8 — 3 miroirs RBAC alignés ensemble** :
|
||||
- `config/sidebar.php` (item + permission ci-dessus),
|
||||
- `frontend/tests/e2e/_fixtures/personas.ts` (persona **Usine** gagne `weighing_tickets.view` + `manage` et `expectedAdminLinks` ; **Compta/Commerciale** : aucun accès),
|
||||
- `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` (miroir back du même persona).
|
||||
6. `make shell` → `php bin/console app:sync-permissions`.
|
||||
|
||||
## Garde-fous (règles ABSOLUES)
|
||||
- `declare(strict_types=1);` partout ; commentaires **en français**, code en anglais.
|
||||
- Permission au format `module.resource.action` snake_case.
|
||||
- Ne PAS créer d'entité ni de migration ici (ticket 1.2).
|
||||
- Pas de hardcode sidebar côté front : elle vient de `/api/sidebar`.
|
||||
|
||||
## Vérification
|
||||
- `make test` (les tests Architecture ne cassent pas).
|
||||
- `make php-cs-fixer-allow-risky`.
|
||||
- `GET /api/modules` retourne `logistique` ; `GET /api/sidebar` : item présent pour Admin/Bureau/Usine, **absent** pour Compta/Commerciale.
|
||||
- Les 3 miroirs RBAC sont cohérents (sinon test E2E faux positif).
|
||||
@@ -0,0 +1,28 @@
|
||||
# Prompt d'implémentation — M5 · ERP-182 (1.2) — Migrer le schéma M5
|
||||
|
||||
Projet **Starseed**. Tâche **back / migration**. Lis `CLAUDE.md` (règles n°11 et n°12), `.claude/rules/backend.md` (§ Migrations) et la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 3.2, § 3.2.bis, § 2.5, § 2.7).
|
||||
|
||||
## Mission
|
||||
Écrire **une** migration Doctrine au namespace racine `DoctrineMigrations` (`migrations/VersionYYYYMMDDHHMMSS.php`, postérieure aux existantes) qui crée tout le schéma M5.
|
||||
|
||||
## Étapes
|
||||
1. **`site.code`** : `ALTER TABLE site ADD COLUMN code VARCHAR(8)` **NULLABLE** → backfill `UPDATE site SET code = LEFT(postal_code, 2) WHERE code IS NULL` → index unique `uq_site_code` (tolère les NULL multiples Postgres).
|
||||
- ⚠ **NE PAS poser `SET NOT NULL` ici.** Sur `make db-reset`, les fixtures `SitesFixtures` insèrent des sites via l'ORM, qui ne connaît `code` que si la propriété est mappée sur l'entité `Site.php` (fait en ERP-183) → sinon `INSERT` sans `code` → violation `NOT NULL` → db-reset plante. Le `NOT NULL` est posé en **ERP-183** (2ᵉ migration) une fois `Site::code` mappé + peuplé. Cf. spec § 2.5.
|
||||
2. Table **`weighing_ticket_counter (site_id PK → site, last_value INT NOT NULL DEFAULT 0)`** (séquence numéro par site, RG-5.02).
|
||||
3. Table **`weighbridge_dsd_counter (site_id PK → site, last_value INT NOT NULL DEFAULT 0)`** (compteur DSD par site, RG-5.04).
|
||||
4. Table **`weighing_ticket`** : copier le DDL de la spec § 3.2 (colonnes `site_id`, `number`, contrepartie `counterparty_type`/`client_id`/`supplier_id`/`other_label`, `immatriculation`/`plate_free_format`, `empty_*`, `full_*`, `net_weight`, `deleted_at` + 4 colonnes Timestampable/Blamable).
|
||||
- Convention `INT GENERATED BY DEFAULT AS IDENTITY`, `TIMESTAMP(0) WITHOUT TIME ZONE` (§ 2.2).
|
||||
- CHECK : `counterparty_type`, `empty_mode`/`full_mode`, et les **3 branches contrepartie** (RG-5.03).
|
||||
- Index unique `(site_id, number)` + index FK (`site`, `client`, `supplier`, `deleted_at`, `created_by`, `updated_by`).
|
||||
5. **Règle ABSOLUE n°12** : `COMMENT ON COLUMN` (FR, ≤ 200 car., sémantique + RG) sur **chaque** colonne créée — cf. échantillon § 3.2.bis. Les 4 colonnes Timestampable/Blamable via `addStandardTimestampableBlamableComments($schema, 'weighing_ticket')`. Bonus `COMMENT ON TABLE`.
|
||||
6. Écrire `down()` symétrique (drop tables + drop colonne `site.code`).
|
||||
|
||||
## Garde-fous
|
||||
- Noms de colonnes **en minuscules** (Postgres).
|
||||
- FK cross-module (`user`, `client`, `supplier`, `site`) → la migration **doit** vivre au namespace racine (règle n°11), sinon `make db-reset` casse l'ordre.
|
||||
- `ON DELETE` : `site` = RESTRICT, `client`/`supplier` = RESTRICT, `created_by`/`updated_by` = SET NULL, compteurs = CASCADE.
|
||||
|
||||
## Vérification
|
||||
- `make db-reset` puis `make migration-migrate` (BDD fraîche) → OK.
|
||||
- `make test` : `ColumnsHaveSqlCommentTest` **vert** (aucune colonne `public` sans `col_description`).
|
||||
- `make php-cs-fixer-allow-risky`.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Prompt d'implémentation — M5 · ERP-183 (1.3) — Entité WeighingTicket + repository + contrat sérialisation
|
||||
|
||||
Projet **Starseed**. Tâche **back**. Lis `CLAUDE.md`, `.claude/rules/backend.md` (Audit, Timestampable/Blamable, Serialization) et la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 3.3, § 4.0, § 2.11). Prérequis : ERP-182 mergé.
|
||||
|
||||
## Mission
|
||||
Créer l'entité Doctrine **`WeighingTicket`** + son `#[ApiResource]` (Get / GetCollection / Post / Patch) + le repository, avec le **contrat de sérialisation posé une seule fois** (read-groups sur chaque propriété affichée — RETEX M1→M4).
|
||||
|
||||
## ⚠ Finaliser `site.code` (dette laissée par ERP-182 — à faire EN PREMIER)
|
||||
ERP-182 a créé `site.code` **nullable** (sinon `db-reset` cassait). Ici on le rend obligatoire, maintenant que l'ORM peut le remplir :
|
||||
1. Mapper la propriété `code` sur l'entité `src/Module/Sites/Domain/Entity/Site.php` (colonne + getter/setter + groupes de sérialisation cohérents avec les autres champs `site:*`).
|
||||
2. Peupler `code` (86 / 17 / 82) dans `SitesFixtures` **et** dans `SeedE2ECommand.php` (sites seedés).
|
||||
3. Ajuster les tests Sites en collision d'unicité : ex. `SiteApiTest` qui crée un site CP `86000` → `code` 86 entre en collision avec la fixture Châtellerault (86) → adapter le CP/code du test.
|
||||
4. **2ᵉ petite migration** (namespace racine) : `ALTER TABLE site ALTER COLUMN code SET NOT NULL;` (+ `COMMENT ON COLUMN` si pas déjà posé).
|
||||
5. `make db-reset` + `make test` doivent rester verts.
|
||||
|
||||
## Étapes — WeighingTicket
|
||||
1. `src/Module/Logistique/Domain/Entity/WeighingTicket.php` (squelette spec § 3.3) :
|
||||
- `#[Auditable]`, `use TimestampableBlamableTrait`, `implements TimestampableInterface, BlamableInterface`.
|
||||
- Relations ORM **partagées** (PAS d'import de logique) : `Client` (M1), `Supplier` (M2), `Site` (Sites) en ManyToOne.
|
||||
- Propriétés : `number`, `site`, `counterpartyType`, `client`, `supplier`, `otherLabel`, `immatriculation`, `plateFreeFormat`, `empty*` (date/weight/dsd/mode/manualNumber), `full*` (idem), `netWeight`.
|
||||
2. **Read-groups (3 maillons § 4.0)** :
|
||||
- `weighing_ticket:read` = champs liste (`number`, `counterpartyType`, `client`, `supplier`, `otherLabel`, `displayDate`, `netWeight`, `plateFreeFormat`, timestamps).
|
||||
- `weighing_ticket:item:read` = détail (`empty*`, `full*`, `site`, `immatriculation`).
|
||||
- Contextes des opérations exactement comme § 3.3 (inclure `client:read`, `supplier:read`, `site:read`, `default:read`).
|
||||
3. Getter calculé **`displayDate`** (= `fullDate ?? emptyDate`) annoté `weighing_ticket:read`.
|
||||
4. Booléen **`plateFreeFormat`** : exposer via getter + `#[SerializedName('plateFreeFormat')]` (piège #3 M1 — la clé doit sortir dans le JSON).
|
||||
5. Sécurité opérations : GET = `is_granted('logistique.weighing_tickets.view')` ; POST/PATCH = `...manage`. **Pas de Delete, pas d'archive.** Provider/Processor référencés (implémentés en ERP-184/185).
|
||||
6. Contraintes `#[Assert\*]` avec **messages FR** ; `Assert\Length.max` aligné sur les colonnes ORM.
|
||||
7. **Libellé i18n audit** : `audit.entity.logistique_weighingticket` dans `frontend/i18n/locales/fr.json`.
|
||||
8. `src/Module/Logistique/Infrastructure/Doctrine/DoctrineWeighingTicketRepository.php`.
|
||||
|
||||
## Garde-fous
|
||||
- `declare(strict_types=1);` ; commentaires FR.
|
||||
- Ne JAMAIS importer une classe d'un autre **module** pour de la logique — seules les entités de référence (Client/Supplier/Site) sont consommées en relation ORM (toléré M1→M4).
|
||||
- Pagination gérée par le Provider (ERP-185) — ne pas désactiver la pagination.
|
||||
|
||||
## Vérification
|
||||
- `make test` : `EntitiesAreTimestampableBlamableTest`, `AuditableEntitiesHaveI18nLabelTest`, `EntityConstraintsHaveFrenchMessageTest` **verts**.
|
||||
- `make php-cs-fixer-allow-risky`.
|
||||
- (La capture JSON réelle du contrat est faite en ERP-187.)
|
||||
@@ -0,0 +1,33 @@
|
||||
# Prompt d'implémentation — M5 · ERP-184 (1.4) — Pesée pont bascule (stub + DSD + endpoint)
|
||||
|
||||
Projet **Starseed**. Tâche **back**. Lis `CLAUDE.md`, `.claude/rules/backend.md` et la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 2.6, § 2.7, § 4.2). Prérequis : ERP-182.
|
||||
|
||||
## Mission
|
||||
Implémenter la pesée déclenchée par les boutons « Pesée bascule » / « Pesée manuelle » : **stub** (pas de liaison matérielle au M5) + allocateur DSD + endpoint API.
|
||||
|
||||
## Étapes
|
||||
1. Contrat `Logistique\Domain\Contract\WeighbridgeReaderInterface` :
|
||||
```php
|
||||
public function read(SiteInterface $site): WeighbridgeReading; // {weight:int kg, dsd:int}
|
||||
```
|
||||
+ `WeighbridgeUnavailableException`.
|
||||
2. Impl `Logistique\Infrastructure\Weighbridge\RandomWeighbridgeReader` : `weight = random_int(10000, 50000)` (RG-5.06), `dsd = DsdAllocator::next($site)`.
|
||||
3. `DsdAllocator` (service) : compteur DSD **par site** sur `weighbridge_dsd_counter`, incrément avec **verrou ligne `SELECT ... FOR UPDATE`** dans une transaction.
|
||||
- AUTO : incrémente et renvoie la nouvelle valeur.
|
||||
- MANUAL : `dsd = dernier dsd du site + 1` (RG-5.04).
|
||||
4. Endpoint **`POST /api/weighbridge_readings`** — ressource virtuelle (DTO `WeighbridgeReadingInput`/`Output`) + Processor dédié, **pas de controller Symfony** :
|
||||
- `{ "mode": "AUTO" }` → `{ weight, dsd, mode }` (site courant via `CurrentSiteProviderInterface`).
|
||||
- `{ "mode": "MANUAL", "weight": <int>, "manualNumber": "<str>" }` → `{ weight, dsd, manualNumber, mode }`.
|
||||
- Erreur `WeighbridgeUnavailableException` → **HTTP 503** explicite « Pont bascule indisponible — passez en pesée manuelle » (RG-5.06).
|
||||
- Sécurité `is_granted('logistique.weighing_tickets.manage')`.
|
||||
5. Le `dsd` renvoyé est **prévisionnel** : noter en commentaire que l'attribution autoritaire est refaite à la création du ticket (ERP-185).
|
||||
|
||||
## Garde-fous
|
||||
- `declare(strict_types=1);` ; commentaires FR.
|
||||
- Consommer `CurrentSiteProviderInterface` (contrat Sites) — pas d'import de logique d'un autre module.
|
||||
- Pas de controller sous `/api` (API Platform).
|
||||
|
||||
## Vérification
|
||||
- `make test` : `WeighbridgeReaderStubTest` (poids ∈ [10000,50000] + chemin erreur → 503), `DsdAllocatorTest` (AUTO incrémente / MANUAL = dernier+1 / par site).
|
||||
- `make php-cs-fixer-allow-risky`.
|
||||
- Appel manuel `POST /api/weighbridge_readings {AUTO}` (token Usine) → poids + dsd cohérents.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Prompt d'implémentation — M5 · ERP-185 (1.5) — Provider + Processor WeighingTicket
|
||||
|
||||
Projet **Starseed**. Tâche **back**. Lis `CLAUDE.md`, `.claude/rules/backend.md` (Pagination, RBAC, Validation) et la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 4.3, § 4.4, § 2.5, § 2.9, § 2.8, § 6, § 2.3). Prérequis : ERP-183, ERP-184.
|
||||
|
||||
## Mission
|
||||
Implémenter la logique métier d'écriture (Processor) et de lecture (Provider) du ticket de pesée.
|
||||
|
||||
## Étapes — `WeighingTicketProcessor` (POST/PATCH)
|
||||
1. **Site courant** : résoudre via `CurrentSiteProviderInterface` → `site_id` (à la création).
|
||||
2. **Numéro `{siteCode}-TP-{NNNN}`** (RG-5.02) : à la création, incrémenter `weighing_ticket_counter` du site avec **`SELECT ... FOR UPDATE`**, formater `%04d`. Numéro **immuable** au PATCH (RG-5.09).
|
||||
3. **DSD autoritaire** : (ré)attribuer `empty_dsd`/`full_dsd` via `DsdAllocator` (verrou) si pesée AUTO (RG-5.04).
|
||||
4. **RG-5.03** (contrepartie) : `#[Assert\Callback]` sur l'entité → selon `counterpartyType`, exiger `client` / `supplier` / `otherLabel` et forcer les autres à `null` (messages FR, `->atPath()` sur le bon champ).
|
||||
5. **RG-5.05** : `net_weight = full_weight - empty_weight` (plein − vide) si les 2 poids présents, sinon `null`.
|
||||
6. **RG-5.01 / RG-5.10** : `WeighingTicketFieldNormalizer` (service appelé avant validation) — `immatriculation` trim+UPPER ; si `!plateFreeFormat` reformate `XX-000-XX` et **rejette en 422** si invalide ; `otherLabel` trim.
|
||||
7. `site` immuable au PATCH (RG-5.09).
|
||||
|
||||
## Étapes — `WeighingTicketProvider` (GET)
|
||||
8. Liste **paginée** via `ApiPlatform\Doctrine\Orm\Paginator` (jamais d'array brut — règle n°13).
|
||||
9. **Cloisonnement par site courant** (§ 2.3) : appliquer le `SiteScopedQueryExtension` existant (ou filtrer sur le site courant).
|
||||
10. Query params : `?search=` (sur `number`, nom client/fournisseur, `other_label`, `immatriculation`), tri `displayDate` (défaut `number DESC`).
|
||||
11. Anti-N+1 : fetch-join `client`/`supplier`/`site` (ManyToOne sûrs).
|
||||
|
||||
## Garde-fous
|
||||
- `declare(strict_types=1);` ; commentaires FR ; messages de validation **FR**.
|
||||
- Toutes les violations 422 portent un `propertyPath` aligné sur les noms de champs (consommé par le front `useFormErrors`).
|
||||
- Pas de controller ; pas de `paginationEnabled: false`.
|
||||
|
||||
## Vérification
|
||||
- `make test` (les tests dédiés sont écrits en ERP-187) : au minimum `CollectionsArePaginatedTest` **vert**.
|
||||
- `make php-cs-fixer-allow-risky`.
|
||||
- Smoke manuel : `POST /api/weighing_tickets` (Usine) → numéro `86-TP-0001` attribué, `net_weight` calculé ; second POST même site → `86-TP-0002`.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Prompt d'implémentation — M5 · ERP-186 (1.6) — Export XLSX des tickets de pesée
|
||||
|
||||
Projet **Starseed**. Tâche **back**. Lis `CLAUDE.md`, `.claude/rules/backend.md` et la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 4.5). Prérequis : ERP-185.
|
||||
|
||||
## Mission
|
||||
Endpoint d'export XLSX de **toute la liste** des tickets de pesée (bouton « Exporter »).
|
||||
|
||||
## Étapes
|
||||
1. Endpoint **`GET /api/weighing_tickets/export.xlsx`** : opération API Platform dédiée avec provider renvoyant un binaire (`Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, `Content-Disposition: attachment`).
|
||||
2. Respecter le **site courant** + les filtres actifs (mêmes critères que la liste, mais **sans pagination** → export complet).
|
||||
3. Colonnes : Numéro, Contrepartie (type + nom Client/Fournisseur/Autre), Date, Immatriculation, Poids vide, Poids plein, **Poids net**, DSD vide, DSD plein.
|
||||
4. Sécurité `is_granted('logistique.weighing_tickets.view')`.
|
||||
5. Whitelister cette opération dans `CollectionsArePaginatedTest::EXCLUDED` (export complet légitime).
|
||||
|
||||
## Garde-fous
|
||||
- `declare(strict_types=1);` ; commentaires FR.
|
||||
- Utiliser le helper XLSX standard du projet (cf. exports M1→M4) — ne pas réinventer.
|
||||
- Pas de controller custom sous `/api` sans `priority: 1` (préférer une opération API Platform).
|
||||
|
||||
## Vérification
|
||||
- `make test` : test de l'export (colonnes + filtrage site) + `CollectionsArePaginatedTest` vert.
|
||||
- `make php-cs-fixer-allow-risky`.
|
||||
- Téléchargement manuel → fichier ouvrable, colonnes correctes, poids net = plein − vide.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Prompt d'implémentation — M5 · ERP-187 (1.7) — Tests PHPUnit RG-5.01→5.10 + capture contrat JSON
|
||||
|
||||
Projet **Starseed**. Tâche **back / tests**. Lis `CLAUDE.md`, `.claude/rules/testing.md` et la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 8, § 4.0.bis). Prérequis : ERP-183 → ERP-186 mergés.
|
||||
|
||||
## Mission
|
||||
Couvrir les RG du M5 par des tests PHPUnit et **capturer la réponse JSON réelle** (DoD) à coller dans la spec avant le démarrage front.
|
||||
|
||||
## Étapes
|
||||
1. **`WeighingTicketSerializationContractTest`** : seeder un ticket complet (contrepartie Client, pesée vide + plein), capturer le JSON **liste** + **détail** (via une variable d'env de dump, cf. pattern M4 `CARRIER_DOD_DUMP`). Vérifier les **4 pièges** :
|
||||
- `client` / `supplier` sortent en **objet embarqué**, pas en IRI nu ;
|
||||
- `plateFreeFormat` présent dans le JSON ;
|
||||
- `number` présent et formaté `{siteCode}-TP-{NNNN}` ;
|
||||
- `netWeight` = `full - empty` (plein − vide).
|
||||
→ **Coller le JSON capturé dans `spec-back.md § 4.0.bis`** (feu vert front).
|
||||
2. `WeighingTicketNumberingTest` : numéro par site, unicité, concurrence (`FOR UPDATE`), immuabilité au PATCH.
|
||||
3. `DsdAllocatorTest` : AUTO incrémente / MANUAL = dernier+1 / compteur par site.
|
||||
4. `WeighbridgeReaderStubTest` : poids ∈ [10000,50000] ; `WeighbridgeUnavailableException` → 503 (RG-5.06).
|
||||
5. `NetWeightTest` : plein − vide ; `null` si une pesée manque (RG-5.05).
|
||||
6. `CounterpartyValidationTest` : RG-5.03 (chaque branche valide + rejets des incohérences).
|
||||
7. `ImmatriculationNormalizationTest` : masque `XX-000-XX`, `plateFreeFormat`, 422 si invalide (RG-5.01).
|
||||
8. **RBAC** : Admin/Bureau/Usine OK ; Compta/Commerciale → 403 ; anonyme → 401.
|
||||
|
||||
## Garde-fous
|
||||
- `declare(strict_types=1);` ; fixtures dédiées sous `tests/Fixtures/`.
|
||||
- **Pas de test E2E** (règle d'or) — PHPUnit uniquement.
|
||||
- Ne pas casser les tests Architecture existants.
|
||||
|
||||
## Vérification
|
||||
- `make test` **vert** (suite complète, dont Architecture).
|
||||
- `spec-back.md § 4.0.bis` contient le JSON RÉEL avec les 4 pièges marqués verts.
|
||||
- `make php-cs-fixer-allow-risky`.
|
||||
@@ -0,0 +1,727 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M5
|
||||
nom: "Tickets de pesée"
|
||||
ecran: tickets-pesee
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0.1
|
||||
date_redaction: 2026-06-17
|
||||
# Historique :
|
||||
# V0.1 (2026-06-17) — Spec back initiale. Restitution + précisions back du docx fonctionnel
|
||||
# « M5-ticket-de-pesee-V02 » (V0.2, 15/06/2026, validation client en attente).
|
||||
# Décisions Matthieu (17/06) :
|
||||
# (1) NOUVEAU module `Logistique` (pas une greffe sur Transport).
|
||||
# (2) Pont bascule = PAS de liaison matérielle au M5 → stub renvoyant un poids
|
||||
# aléatoire ∈ [10000, 50000] kg. Driver réel = hors périmètre (ticket dédié).
|
||||
# (3) DSD = compteur de pesée du pont (en manuel : dernier dsd + 1).
|
||||
# (4) Poids net (non précisé par le docx) = poids plein − poids vide, calculé serveur
|
||||
# (CONFIRMÉ Matthieu 17/06 — § 2.8 / RG-5.05).
|
||||
# Maquette Figma (node 1322-16774, board « Module 5 : Ticket de pesée ») intégrée le 17/06 :
|
||||
# les DEUX blocs (vide + plein) portent « Pesée bascule » + « Pesée manuelle » ;
|
||||
# DSD séquentiel +1 par pesée (16619 → 16620) ; contrepartie portée par le bloc vide.
|
||||
|
||||
# === LIENS ===
|
||||
spec_front: ./spec-front.md
|
||||
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1322-16774&p=f&m=dev"
|
||||
trace_fonctionnelle: "uploads/M5-ticket-de-pesee-V02.pdf (V0.2, 15/06/2026, validation client en attente)"
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_project_id: 6
|
||||
lesstime_taskgroup_id: 33 # M5 — Tickets de pesée (ERP-181 → ERP-192)
|
||||
statut_global: pret_a_dev
|
||||
|
||||
# === DÉPENDANCES AMONT ===
|
||||
depend_de:
|
||||
- Sites # SitesModule + sélecteur de site (CurrentSiteProviderInterface) + SiteScopedQueryExtension → numérotation + cloisonnement
|
||||
- Commercial # Client (M1) + Supplier (M2) → contrepartie du ticket (Client / Fournisseur)
|
||||
- Core # User, Role, Permission, Audit, JWT
|
||||
- Shared # TimestampableBlamableTrait + Subscriber (ERP-52)
|
||||
---
|
||||
|
||||
# Spec back — Module 5 : Tickets de pesée
|
||||
|
||||
## 1. Contexte
|
||||
|
||||
Cette spec **complète et précise** la [spec front V0.1](./spec-front.md) (docx `M5-ticket-de-pesee-V02`, V0.2 du 15/06/2026) avec tout ce qui touche au back : décisions d'archi, modèle de données, migration, API REST, RBAC, règles de gestion (RG-5.01 + précisions back RG-5.02 → RG-5.10), intégration pont bascule (stub), tests, hors-périmètre.
|
||||
|
||||
**Module cible** : **NOUVEAU module `Logistique`** (`src/Module/Logistique/`) — DÉCISION Matthieu (17/06). Le docx parle de « page d'entrée du Module *Logistique* » : on en fait un module à part entière (scaffolding via le skill `create-module`), distinct de `Transport` (M4). Son premier périmètre fonctionnel exposé est le **ticket de pesée** (entité `WeighingTicket`).
|
||||
|
||||
> **Distinction Transport (M4) vs Logistique (M5)** : `Transport` = référentiel des transporteurs (qui transporte). `Logistique` = opérations physiques sur site, à commencer par la **pesée au pont bascule**. Les deux peuvent à terme cohabiter dans une même section sidebar « Logistique » (cf. § 5.3), mais restent **deux modules** (activables/désactivables séparément).
|
||||
|
||||
> **RETEX obligatoire (M1→M4)** : ~80 % des frictions venaient du **contrat de sérialisation** (groupes / sous-ressources / embed), pas du métier. La section § 4.0 applique ce RETEX au M5. On réutilise aussi le pattern Provider/Processor + normalisation serveur + Timestampable/Blamable + audit i18n posé aux modules précédents.
|
||||
|
||||
**Dépendances déjà en place sur `develop`** :
|
||||
- `Sites` → 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82) ; **sélecteur de site** exposé via `Sites\Application\Service\CurrentSiteProviderInterface` ; `SiteScopedQueryExtension` (filtrage par site courant) ; `SiteInterface` (contrat partagé).
|
||||
- `Commercial` → `Client` (M1) + `Supplier` (M2).
|
||||
- `Shared` → `TimestampableBlamableTrait` + `Subscriber` (ERP-52).
|
||||
- `Core` → User, Role, Permission, Audit, JWT.
|
||||
|
||||
## 2. Décisions d'archi
|
||||
|
||||
### 2.1 Nouveau module `Logistique` + entité `WeighingTicket`
|
||||
|
||||
Création du module **`Logistique`** :
|
||||
- `src/Module/Logistique/LogistiqueModule.php` — `ID = 'logistique'`, `LABEL = 'Logistique'`, `REQUIRED = false`, `permissions()` (§ 5.1).
|
||||
- Ajout dans `config/modules.php` : `LogistiqueModule::class`.
|
||||
- `Domain/`, `Application/`, `Infrastructure/` (arborescence DDD standard).
|
||||
- Layer front `frontend/modules/logistique/` (kebab-case — règle naming).
|
||||
|
||||
Entité racine : **`WeighingTicket`** (ticket de pesée) sous `src/Module/Logistique/Domain/Entity/`, avec ses **deux pesées** (vide + plein) modélisées en colonnes plates (§ 2.4).
|
||||
|
||||
**Référentiels cross-module consommés en relation ORM partagée (PAS d'import de logique)** — exactement comme M2/M3/M4 : le ticket référence `Client` (M1), `Supplier` (M2) et `Site` (Sites) via des **relations ORM** (ManyToOne). Ce sont des **données de référence partagées**, pas de la logique inter-module (aucun service/repository d'un autre module appelé). La seule logique cross-module consommée est `CurrentSiteProviderInterface` (déjà un **contrat** exposé par Sites — autorisé par la règle ABSOLUE n°1).
|
||||
|
||||
### 2.2 IDs — convention `INT` (alignée Core/Commercial/Sites)
|
||||
|
||||
Le module `Logistique` est un **nouveau** module métier hors périmètre Transport : on s'aligne sur la convention **`INT GENERATED BY DEFAULT AS IDENTITY`** des modules historiques (Core / Commercial / Sites), et **non** sur le `BIGINT` du module Transport. Horodatages en `TIMESTAMP(0) WITHOUT TIME ZONE` (le `TimestampableBlamableTrait` mappe `datetime_immutable`).
|
||||
|
||||
### 2.3 Cloisonnement par site courant (DÉCISION par défaut — à confirmer)
|
||||
|
||||
> **Décision par défaut** : les tickets de pesée sont des **données opérationnelles rattachées à un site physique** (le pont bascule est sur site). On **cloisonne la liste par le site courant** (sélecteur de site en haut de l'app) via le `SiteScopedQueryExtension` **déjà existant** (Sites). Un utilisateur voit les tickets du site actif.
|
||||
|
||||
- Colonne `site_id` NOT NULL sur `weighing_ticket` (renseignée à la création depuis `CurrentSiteProviderInterface`).
|
||||
- `GET /api/weighing_tickets` filtré sur le site courant (extension automatique).
|
||||
- Le **numéro** du ticket encode déjà le site (RG-5.02) → cohérent avec le cloisonnement.
|
||||
|
||||
> **À confirmer client** : si le métier veut une **vue multi-sites** (tous sites confondus), retirer le cloisonnement et ajouter un filtre `?siteId=`. Tracé HP-M5-01 (§ 9).
|
||||
|
||||
### 2.4 Modélisation des deux pesées — colonnes plates (pas de sous-entité)
|
||||
|
||||
Un ticket porte **exactement deux pesées** : une **à vide** (tare) et une **à plein** (brut). Plutôt qu'une sous-collection `Weighing` (1:n), on modélise **deux jeux de colonnes plates** sur `weighing_ticket` :
|
||||
|
||||
| Groupe | Colonnes |
|
||||
|---|---|
|
||||
| Pesée à vide | `empty_date`, `empty_weight`, `empty_dsd`, `empty_mode` (AUTO/MANUAL), `empty_manual_number` |
|
||||
| Pesée à plein | `full_date`, `full_weight`, `full_dsd`, `full_mode` (AUTO/MANUAL), `full_manual_number` |
|
||||
|
||||
Justification : cardinalité **fixe** (toujours 1 vide + 1 plein), pas de tri/ajout dynamique, requêtes/exports plus simples, audit lisible. (Alternative sous-entité `Weighing` documentée mais non retenue — over-engineering pour 2 lignes figées.)
|
||||
|
||||
> **Champs `*_manual_number`** : « numéro de pesée » saisi en **pesée manuelle** (référence d'un ticket papier / autre bascule — distinct du DSD, cf. RG-5.04). Nullable (rempli seulement si `mode = MANUAL`).
|
||||
> **Maquette (17/06)** : les **deux** blocs (vide ET plein) portent les boutons « Pesée bascule » + « Pesée manuelle » — le modèle symétrique (`empty_*` ET `full_*` avec mode AUTO/MANUAL) est donc bien utilisé des deux côtés. (Le texte du docx V0.2 ne mentionnait la manuelle que sur le bloc vide ; la maquette fait foi.)
|
||||
|
||||
### 2.5 Numérotation `{siteCode}-TP-{NNNN}` (RG-5.02)
|
||||
|
||||
> **Décision** : chaque ticket reçoit un **numéro unique par site** au format `{siteCode}-TP-{NNNN}` (ex. `86-TP-0001`). La séquence est **propre à chaque site** → `86-TP-0001` et `17-TP-0001` coexistent (cf. docx).
|
||||
|
||||
- **`siteCode`** : le `Site` actuel n'a **pas** de colonne `code`. On **ajoute** `site.code` (VARCHAR court, ex. `86`/`17`/`82`) — backfill par défaut = 2 premiers chiffres du `postal_code`, valeur éditable ensuite côté admin Sites. Justification : un code explicite est plus robuste qu'une dérivation implicite du CP (collisions de département possibles). Petit débordement assumé sur le module Sites (1 colonne).
|
||||
- ⚠ **Cadencement en 2 temps (RETEX dev ERP-182, 17/06)** : `NOT NULL` ne peut PAS être posé dans la migration M5 seule. Sur base fraîche (`make db-reset`), les fixtures `SitesFixtures` font `new Site(...)` via l'ORM, qui ne connaît `code` que si la **propriété est mappée sur l'entité** `Site.php` (pas le cas avant ERP-183) → `INSERT` sans `code` → violation `NOT NULL`. Décision :
|
||||
- **ERP-182 (migration)** : créer `site.code` **NULLABLE** + backfill + index unique (les `NULL` multiples sont tolérés par l'index unique Postgres). `make db-reset` passe, aucun test cassé.
|
||||
- **ERP-183 (entité)** : mapper `Site::code` (propriété + getter/setter), le peupler dans `SitesFixtures` (86/17/82) + `SeedE2ECommand`, ajuster les tests Sites en collision d'unicité (ex. `SiteApiTest` créant un site CP `86000` → `code` 86 = collision avec Châtellerault), **puis** poser `NOT NULL` via une **2ᵉ petite migration**.
|
||||
- **Séquence par site** : table dédiée `weighing_ticket_counter (site_id PK, last_value INT)`. À la création : `SELECT ... FOR UPDATE` sur la ligne du site (verrou ligne) → `last_value + 1`, formaté `%04d` (zéro-padding 4 chiffres, débordement naturel au-delà de 9999). Garantit l'unicité même en concurrence.
|
||||
- Le numéro est **immuable** après création (pas modifiable à l'édition).
|
||||
- Index unique `uq_weighing_ticket_number (site_id, number)`.
|
||||
|
||||
> **Alternative écartée** : séquence Postgres par site (création dynamique de séquences) — moins portable, plus lourde à seeder. La table compteur + `FOR UPDATE` est le pattern retenu.
|
||||
|
||||
### 2.6 Intégration pont bascule — stub au M5 (RG-5.06)
|
||||
|
||||
> **Décision Matthieu (17/06)** : **aucune liaison matérielle** au M5. Le « pont bascule » est **simulé** : il renvoie un **poids aléatoire ∈ [10000, 50000] kg**.
|
||||
|
||||
- Contrat : `Logistique\Domain\Contract\WeighbridgeReaderInterface`
|
||||
```php
|
||||
interface WeighbridgeReaderInterface
|
||||
{
|
||||
/** @throws WeighbridgeUnavailableException si la bascule ne répond pas (→ bascule manuelle). */
|
||||
public function read(SiteInterface $site): WeighbridgeReading; // {weight: int (kg), dsd: int}
|
||||
}
|
||||
```
|
||||
- Implémentation livrée au M5 : `Infrastructure\Weighbridge\RandomWeighbridgeReader` → `weight = random_int(10000, 50000)`, `dsd = nextDsd(site)` (RG-5.04).
|
||||
- **Driver matériel réel** (protocole série/TCP de l'indicateur de pesage, parsing trame, reconnexion) = **hors périmètre M5**, tracé HP-M5-02 (§ 9). Le jour venu, on substitue l'implémentation derrière l'interface — **zéro impact** sur les écrans / l'API.
|
||||
- **Gestion d'erreur** (RG-5.06) : si `read()` lève `WeighbridgeUnavailableException`, l'API renvoie un **422/503 explicite** « Pont bascule indisponible — passez en pesée manuelle ». Le front affiche le message dans la modal et propose la pesée manuelle (le stub ne lève jamais l'exception au M5, mais le chemin d'erreur est implémenté et testé).
|
||||
|
||||
### 2.7 DSD — compteur de pesée du pont (RG-5.04)
|
||||
|
||||
> **Décision Matthieu (17/06)** : le **DSD** est un **compteur de pesée** (index séquentiel des pesées du pont). Chaque pesée (vide OU plein) consomme **une** valeur DSD.
|
||||
|
||||
- Compteur **par site** (un pont par site) : table `weighbridge_dsd_counter (site_id PK, last_value INT)` (verrou ligne `FOR UPDATE`, même pattern que le compteur de numéro).
|
||||
- **Pesée bascule (AUTO)** : la lecture incrémente le compteur du site et renvoie la nouvelle valeur (le stub fait pareil ; un vrai pont renverrait son propre index, qu'on persisterait).
|
||||
- **Pesée manuelle** : `dsd = dernier dsd du site + 1` (le docx : « le dsd est automatiquement calculé en fonction du dernier dsd en base de données »).
|
||||
- Un ticket complet (vide + plein en AUTO) consomme **2 incréments DSD** (`empty_dsd`, `full_dsd`).
|
||||
|
||||
### 2.8 Poids net — `plein − vide`, calculé serveur (RG-5.05)
|
||||
|
||||
> **Le docx ne définit pas** le calcul du poids affiché en liste (colonne « Poids »). **CONFIRMÉ Matthieu (17/06)** : **poids net = poids plein − poids vide**.
|
||||
|
||||
- Stocké en colonne dérivée `net_weight` (INT, kg), **recalculé serveur** par le `WeighingTicketProcessor` à chaque POST/PATCH dès que `empty_weight` ET `full_weight` sont renseignés (sinon `null`).
|
||||
- La colonne **liste « Poids » = `net_weight`** (cf. § 4.0). Le détail/ticket affiche vide + plein + net.
|
||||
- Exemple maquette : plein `14 300` − vide `7 150` = **net `7 150` kg**.
|
||||
|
||||
### 2.9 Contrepartie CLIENT / FOURNISSEUR / AUTRE (RG-5.03)
|
||||
|
||||
Le formulaire principal porte un sélecteur **« Fournisseur / Client / Autre »** qui pilote des champs conditionnels (docx p.4). Le back **ne maintient pas de state machine** : il stocke et **valide la cohérence** au POST/PATCH.
|
||||
|
||||
| `counterparty_type` | Champs requis | Champs forcés nuls |
|
||||
|---|---|---|
|
||||
| `CLIENT` | `client_id` (FK Client) | `supplier_id`, `other_label` |
|
||||
| `FOURNISSEUR` | `supplier_id` (FK Supplier) | `client_id`, `other_label` |
|
||||
| `AUTRE` | `other_label` (texte libre) | `client_id`, `supplier_id` |
|
||||
|
||||
Validation via `#[Assert\Callback]` + CHECK Postgres (garde-fous miroir M4 § 3.2).
|
||||
|
||||
### 2.10 Masque immatriculation & « Tout format » (RG-5.01)
|
||||
|
||||
- `immatriculation` : par défaut **masque `XX-000-XX`** (plaque FR SIV). Si **`plate_free_format = true`** (« Tout format » coché), le masque est désactivé (saisie libre — anciennes plaques, étranger, engins).
|
||||
- **Champs connectés entre les deux formulaires** (vide ⇄ plein) : `immatriculation` et `plate_free_format` sont **portés par le ticket** (une seule valeur, partagée par les 2 formulaires) — c'est le même véhicule. Pas de duplication.
|
||||
- Normalisation serveur : `immatriculation` → trim + UPPER + (si masque) re-formatage `XX-000-XX` ; rejet 422 si format invalide et `plate_free_format = false`.
|
||||
|
||||
### 2.11 Audit & traces temporelles
|
||||
|
||||
Pattern Starseed standard (miroir M1→M4) :
|
||||
- `#[Auditable]` sur `WeighingTicket`. Pas de champ sensible (password/token) → pas d'`#[AuditIgnore]`.
|
||||
- Audit des FK (`client`, `supplier`, `site`) tracé automatiquement.
|
||||
- `WeighingTicket implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait` (4 colonnes standard).
|
||||
- **Libellé i18n** (règle ABSOLUE backend — `AuditableEntitiesHaveI18nLabelTest`) : ajouter `audit.entity.logistique_weighingticket` dans `frontend/i18n/locales/fr.json` (clé = `strtolower(module)` + `_` + `strtolower(Entity)`).
|
||||
|
||||
### 2.12 Impression du ticket / bon de pesée (RG-5.08)
|
||||
|
||||
> **OWNER : Tristan.** La **réalisation du bon d'impression** (gabarit du ticket de pesée, mise en page, déclenchement de l'impression) est **prise en charge par Tristan lui-même** — hors de la découpe back/front standard du M5. Cette spec en pose **le contrat attendu** (déclencheur, contenu, données disponibles) pour qu'il puisse s'y brancher sans rétro-spec.
|
||||
|
||||
Contrat attendu :
|
||||
- **Déclencheur** : à la **validation** (création), l'API renvoie le ticket complet ; le front ouvre une **modal d'impression**. En **modification**, un bouton **« Imprimer »** est disponible (absent à l'ajout — docx / RG-5.08).
|
||||
- **Contenu minimal du bon** : numéro (`{siteCode}-TP-{NNNN}`), site, contrepartie (Client / Fournisseur / Autre + libellé), immatriculation, **pesée à vide** (date/poids/DSD), **pesée à plein** (date/poids/DSD), **poids net** (= plein − vide), date d'édition.
|
||||
- **Données** : toutes disponibles dans la réponse `GET /api/weighing_tickets/{id}` (§ 4.0) — aucun champ supplémentaire requis côté API. Si Tristan opte pour un **PDF serveur**, prévoir l'endpoint `GET /api/weighing_tickets/{id}/print.pdf` (HP-M5-04) ; sinon impression navigateur d'un gabarit front.
|
||||
|
||||
### 2.13 Pas d'archive ; soft delete préparé non exposé
|
||||
|
||||
Le docx M5 **ne prévoit pas** d'archivage (contrairement au M4). On **n'expose pas** d'archive. On prépare néanmoins une colonne `deleted_at` (soft delete technique) **non exposée** au M5 (`DELETE` non exposé → 404). Cohérent avec le pattern projet.
|
||||
|
||||
## 3. Modèle de données
|
||||
|
||||
### 3.1 Diagramme
|
||||
|
||||
```
|
||||
+------------------+
|
||||
| site (Sites) | + NOUVELLE colonne `code` (86/17/82)
|
||||
+------------------+
|
||||
^ ^ ^
|
||||
site_id | | site_id| site_id
|
||||
+---------------+ | +------------------------+
|
||||
| | |
|
||||
+-----------------------+ +--------------------------+ +--------------------------+
|
||||
| weighing_ticket_counter| | weighbridge_dsd_counter | | weighing_ticket |
|
||||
| site_id PK | | site_id PK | | id (PK) |
|
||||
| last_value INT | | last_value INT | | number (UNIQUE / site) |
|
||||
+-----------------------+ +--------------------------+ | site_id (FK) |
|
||||
(séquence n° ticket) (compteur DSD pont) | counterparty_type |
|
||||
| client_id (FK M1, null) |--> client (M1)
|
||||
| supplier_id (FK M2, null)|--> supplier (M2)
|
||||
| other_label (null) |
|
||||
| immatriculation |
|
||||
| plate_free_format |
|
||||
| empty_* (date/weight/dsd/mode/manual_number) |
|
||||
| full_* (date/weight/dsd/mode/manual_number) |
|
||||
| net_weight (dérivé) |
|
||||
| deleted_at (soft, non exposé) |
|
||||
+--------------------------+
|
||||
```
|
||||
|
||||
### 3.2 Migration Doctrine — SQL Postgres
|
||||
|
||||
Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/VersionYYYYMMDDHHMMSS.php` (à dater, postérieur aux migrations existantes).
|
||||
|
||||
> **Même justification qu'aux M1→M4** : la migration crée un schéma avec **FK cross-module** (`user`, `client`, `supplier`, `site`). Le namespace modulaire casserait l'ordre (`make db-reset`) — exception racine de la règle ABSOLUE n°11.
|
||||
|
||||
> **Rappel règle ABSOLUE n°12** : chaque colonne créée DOIT recevoir son `COMMENT ON COLUMN` (FR, ≤ 200 car., sémantique + contrainte/RG). Les 4 colonnes Timestampable/Blamable passent par le helper `addStandardTimestampableBlamableComments`. SQL ci-dessous *illustratif* (convention `INT GENERATED BY DEFAULT AS IDENTITY`, `TIMESTAMP(0)`).
|
||||
|
||||
```sql
|
||||
-- =====================================================================
|
||||
-- Ajout d'un code de site (préfixe de numérotation TP) — § 2.5
|
||||
-- =====================================================================
|
||||
-- ⚠ NULLABLE au M5 (ERP-182). Le SET NOT NULL est posé en ERP-183, une fois Site::code
|
||||
-- mappé sur l'entité et peuplé dans les fixtures (sinon db-reset casse — cf. § 2.5).
|
||||
ALTER TABLE site ADD COLUMN code VARCHAR(8);
|
||||
-- Backfill : 2 premiers chiffres du code postal (dépt) par défaut, éditable ensuite.
|
||||
UPDATE site SET code = LEFT(postal_code, 2) WHERE code IS NULL;
|
||||
-- Index unique tolérant les NULL (Postgres : plusieurs NULL autorisés) — OK tant que code nullable.
|
||||
CREATE UNIQUE INDEX uq_site_code ON site (code);
|
||||
-- ERP-183 (2ᵉ migration) : ALTER TABLE site ALTER COLUMN code SET NOT NULL;
|
||||
|
||||
-- =====================================================================
|
||||
-- Compteur de numéro de ticket (séquence par site) — RG-5.02
|
||||
-- =====================================================================
|
||||
CREATE TABLE weighing_ticket_counter (
|
||||
site_id INT PRIMARY KEY REFERENCES site(id) ON DELETE CASCADE,
|
||||
last_value INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- Compteur DSD (pesée du pont, par site) — RG-5.04
|
||||
-- =====================================================================
|
||||
CREATE TABLE weighbridge_dsd_counter (
|
||||
site_id INT PRIMARY KEY REFERENCES site(id) ON DELETE CASCADE,
|
||||
last_value INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- Table principale `weighing_ticket`
|
||||
-- =====================================================================
|
||||
CREATE TABLE weighing_ticket (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
|
||||
number VARCHAR(20) NOT NULL, -- {siteCode}-TP-{NNNN} (RG-5.02)
|
||||
-- Contrepartie (RG-5.03)
|
||||
counterparty_type VARCHAR(12) NOT NULL, -- CLIENT|FOURNISSEUR|AUTRE
|
||||
client_id INT REFERENCES client(id) ON DELETE RESTRICT,
|
||||
supplier_id INT REFERENCES supplier(id) ON DELETE RESTRICT,
|
||||
other_label VARCHAR(255),
|
||||
-- Véhicule (RG-5.01, partagé entre les 2 formulaires)
|
||||
immatriculation VARCHAR(20) NOT NULL,
|
||||
plate_free_format BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
-- Pesée à vide (§ 2.4)
|
||||
empty_date TIMESTAMP(0) WITHOUT TIME ZONE,
|
||||
empty_weight INT, -- kg
|
||||
empty_dsd INT,
|
||||
empty_mode VARCHAR(8), -- AUTO|MANUAL
|
||||
empty_manual_number VARCHAR(50), -- numéro de pesée manuelle (RG-5.04)
|
||||
-- Pesée à plein (§ 2.4)
|
||||
full_date TIMESTAMP(0) WITHOUT TIME ZONE,
|
||||
full_weight INT, -- kg
|
||||
full_dsd INT,
|
||||
full_mode VARCHAR(8), -- AUTO|MANUAL
|
||||
full_manual_number VARCHAR(50),
|
||||
-- Dérivé (RG-5.05)
|
||||
net_weight INT, -- full_weight - empty_weight (RG-5.05)
|
||||
-- Soft delete (préparé, non exposé au M5)
|
||||
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE,
|
||||
-- Timestampable + Blamable
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
CONSTRAINT chk_wt_counterparty_type
|
||||
CHECK (counterparty_type IN ('CLIENT','FOURNISSEUR','AUTRE')),
|
||||
CONSTRAINT chk_wt_empty_mode CHECK (empty_mode IS NULL OR empty_mode IN ('AUTO','MANUAL')),
|
||||
CONSTRAINT chk_wt_full_mode CHECK (full_mode IS NULL OR full_mode IN ('AUTO','MANUAL')),
|
||||
-- RG-5.03 : cohérence contrepartie
|
||||
CONSTRAINT chk_wt_client_branch CHECK (
|
||||
counterparty_type <> 'CLIENT' OR (client_id IS NOT NULL AND supplier_id IS NULL AND other_label IS NULL)
|
||||
),
|
||||
CONSTRAINT chk_wt_supplier_branch CHECK (
|
||||
counterparty_type <> 'FOURNISSEUR' OR (supplier_id IS NOT NULL AND client_id IS NULL AND other_label IS NULL)
|
||||
),
|
||||
CONSTRAINT chk_wt_other_branch CHECK (
|
||||
counterparty_type <> 'AUTRE' OR (other_label IS NOT NULL AND client_id IS NULL AND supplier_id IS NULL)
|
||||
)
|
||||
);
|
||||
CREATE UNIQUE INDEX uq_weighing_ticket_number ON weighing_ticket (site_id, number);
|
||||
CREATE INDEX idx_wt_site ON weighing_ticket (site_id);
|
||||
CREATE INDEX idx_wt_client ON weighing_ticket (client_id);
|
||||
CREATE INDEX idx_wt_supplier ON weighing_ticket (supplier_id);
|
||||
CREATE INDEX idx_wt_deleted_at ON weighing_ticket (deleted_at);
|
||||
CREATE INDEX idx_wt_created_by ON weighing_ticket (created_by);
|
||||
CREATE INDEX idx_wt_updated_by ON weighing_ticket (updated_by);
|
||||
```
|
||||
|
||||
### 3.2.bis Commentaires SQL obligatoires (échantillon)
|
||||
|
||||
```php
|
||||
$this->addSql("COMMENT ON TABLE weighing_ticket IS 'Tickets de pesée (M5 Logistique) — pesée à vide + à plein au pont bascule, contrepartie Client/Fournisseur/Autre.'");
|
||||
$this->addSql("COMMENT ON COLUMN site.code IS 'Code court du site (ex. 86/17/82) — préfixe de numérotation des tickets de pesée (RG-5.02). Unique.'");
|
||||
$this->addSql("COMMENT ON COLUMN weighing_ticket.number IS 'Numéro {siteCode}-TP-{NNNN}, unique par site, immuable. Séquence weighing_ticket_counter (RG-5.02).'");
|
||||
$this->addSql("COMMENT ON COLUMN weighing_ticket.counterparty_type IS 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (RG-5.03). Pilote l''obligation client_id / supplier_id / other_label.'");
|
||||
$this->addSql("COMMENT ON COLUMN weighing_ticket.immatriculation IS 'Plaque du véhicule, partagée entre pesée vide et plein. Masque XX-000-XX sauf si plate_free_format (RG-5.01).'");
|
||||
$this->addSql("COMMENT ON COLUMN weighing_ticket.plate_free_format IS '« Tout format » : désactive le masque XX-000-XX de l''immatriculation (RG-5.01). Partagé entre les 2 formulaires.'");
|
||||
$this->addSql("COMMENT ON COLUMN weighing_ticket.empty_dsd IS 'Compteur DSD du pont à la pesée à vide. AUTO=valeur du pont ; MANUAL=dernier dsd du site +1 (RG-5.04).'");
|
||||
$this->addSql("COMMENT ON COLUMN weighing_ticket.empty_manual_number IS 'Numéro de pesée saisi en pesée manuelle (distinct du DSD) — formulaire à vide (RG-5.04).'");
|
||||
$this->addSql("COMMENT ON COLUMN weighing_ticket.net_weight IS 'Poids net = full_weight - empty_weight (kg), calculé serveur (RG-5.05). Colonne Poids de la liste.'");
|
||||
$this->addSql("COMMENT ON COLUMN weighbridge_dsd_counter.last_value IS 'Dernière valeur DSD attribuée pour le site (pont bascule). Incrément verrouillé FOR UPDATE (RG-5.04).'");
|
||||
$this->addSql("COMMENT ON COLUMN weighing_ticket_counter.last_value IS 'Dernier numéro de ticket attribué pour le site. Incrément verrouillé FOR UPDATE (RG-5.02).'");
|
||||
// + COMMENT ON COLUMN sur TOUTES les autres colonnes métier (règle n°12)
|
||||
$this->addStandardTimestampableBlamableComments($schema, 'weighing_ticket');
|
||||
```
|
||||
|
||||
### 3.3 Entité `WeighingTicket` — squelette (extrait)
|
||||
|
||||
Pattern jumeau de `Carrier`/`Supplier` (`#[Auditable]`, `TimestampableBlamableTrait`). **Chaque propriété affichée porte un read-group** (RETEX M1).
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Commercial\Domain\Entity\Client; // relation ORM partagée (§ 2.1)
|
||||
use App\Module\Commercial\Domain\Entity\Supplier; // relation ORM partagée (§ 2.1)
|
||||
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagée (§ 2.1)
|
||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor;
|
||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketProvider;
|
||||
use App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository;
|
||||
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\Serializer\Attribute\SerializedName;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('logistique.weighing_tickets.view')",
|
||||
normalizationContext: ['groups' => ['weighing_ticket:read', 'client:read', 'supplier:read', 'site:read', 'default:read']],
|
||||
provider: WeighingTicketProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('logistique.weighing_tickets.view')",
|
||||
normalizationContext: ['groups' => ['weighing_ticket:read', 'weighing_ticket:item:read', 'client:read', 'supplier:read', 'site:read', 'default:read']],
|
||||
provider: WeighingTicketProvider::class,
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('logistique.weighing_tickets.manage')",
|
||||
normalizationContext: ['groups' => ['weighing_ticket:read', 'weighing_ticket:item:read', 'client:read', 'supplier:read', 'site:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['weighing_ticket:write']],
|
||||
processor: WeighingTicketProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('logistique.weighing_tickets.manage')",
|
||||
normalizationContext: ['groups' => ['weighing_ticket:read', 'weighing_ticket:item:read', 'client:read', 'supplier:read', 'site:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['weighing_ticket:write']],
|
||||
provider: WeighingTicketProvider::class,
|
||||
processor: WeighingTicketProcessor::class,
|
||||
),
|
||||
// Pas de Delete au M5 (HP). Pas d'archive (hors docx).
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineWeighingTicketRepository::class)]
|
||||
#[ORM\Table(name: 'weighing_ticket')]
|
||||
#[Auditable]
|
||||
class WeighingTicket implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
#[Groups(['weighing_ticket:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
/** Numéro {siteCode}-TP-{NNNN} — attribué serveur, lecture seule (RG-5.02). */
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Groups(['weighing_ticket:read'])]
|
||||
private ?string $number = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Site::class)]
|
||||
#[ORM\JoinColumn(name: 'site_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
#[Groups(['weighing_ticket:read'])] // renseigné serveur depuis le site courant (§ 2.3)
|
||||
private ?Site $site = null;
|
||||
|
||||
#[ORM\Column(length: 12)]
|
||||
#[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', 'AUTRE'], message: 'Type de contrepartie invalide.')]
|
||||
#[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.')]
|
||||
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||
private ?string $counterpartyType = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Client::class)]
|
||||
#[ORM\JoinColumn(name: 'client_id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||
private ?Client $client = null; // requis si counterpartyType=CLIENT (Callback RG-5.03)
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Supplier::class)]
|
||||
#[ORM\JoinColumn(name: 'supplier_id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||
private ?Supplier $supplier = null; // requis si counterpartyType=FOURNISSEUR
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||
private ?string $otherLabel = null; // requis si counterpartyType=AUTRE
|
||||
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\NotBlank(message: 'L''immatriculation est obligatoire.')]
|
||||
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||
private ?string $immatriculation = null; // masque XX-000-XX sauf plateFreeFormat (RG-5.01)
|
||||
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||
private bool $plateFreeFormat = false;
|
||||
|
||||
// === Pesée à vide ===
|
||||
#[ORM\Column(name: 'empty_date', type: 'datetime_immutable', nullable: true)]
|
||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||
private ?\DateTimeImmutable $emptyDate = null;
|
||||
|
||||
#[ORM\Column(name: 'empty_weight', nullable: true)]
|
||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||
private ?int $emptyWeight = null; // kg — readonly UI, rempli par la pesée (RG-5.07)
|
||||
|
||||
#[ORM\Column(name: 'empty_dsd', nullable: true)]
|
||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||
private ?int $emptyDsd = null;
|
||||
|
||||
#[ORM\Column(name: 'empty_mode', length: 8, nullable: true)]
|
||||
#[Assert\Choice(choices: ['AUTO', 'MANUAL'], message: 'Mode de pesée invalide.')]
|
||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||
private ?string $emptyMode = null;
|
||||
|
||||
#[ORM\Column(name: 'empty_manual_number', length: 50, nullable: true)]
|
||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||
private ?string $emptyManualNumber = null;
|
||||
|
||||
// === Pesée à plein (mêmes colonnes, préfixe full*) ===
|
||||
// fullDate / fullWeight / fullDsd / fullMode / fullManualNumber ...
|
||||
|
||||
/** Poids net dérivé — calculé serveur (RG-5.05). */
|
||||
#[ORM\Column(name: 'net_weight', nullable: true)]
|
||||
#[Groups(['weighing_ticket:read'])]
|
||||
private ?int $netWeight = null;
|
||||
|
||||
// RG-5.03 (contrepartie) + RG-5.01 (immat) : cohérence via #[Assert\Callback] (§ 7).
|
||||
// ... getters/setters ...
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠ `Client` / `Supplier` / `Site` appartiennent à d'autres modules — on consomme leurs read-groups (`client:read`, `supplier:read`, `site:read`), **pas de logique inter-module** (§ 2.1).
|
||||
|
||||
## 4. API REST (API Platform)
|
||||
|
||||
### 4.0 Contrat de sérialisation (RETEX M1 — section critique)
|
||||
|
||||
> **Leçon M1→M4** : pour **chaque champ affiché** (liste OU détail), les **3 maillons** doivent être prouvés : (a) groupe sur la propriété, (b) groupe dans le `normalizationContext` de l'opération, (c) read-group de l'entité imbriquée présent dans le contexte parent.
|
||||
|
||||
**Contexte par opération** :
|
||||
|
||||
| Opération | `normalizationContext` (groupes) |
|
||||
|---|---|
|
||||
| `GetCollection` (liste) | `weighing_ticket:read` + `client:read` + `supplier:read` + `site:read` + `default:read` |
|
||||
| `Get` / `Post` / `Patch` (détail) | + `weighing_ticket:item:read` |
|
||||
|
||||
**LISTE — colonne datatable → maillons** (docx p.3 : Numéro, Client, Fournisseur, Autre, Date, Poids) :
|
||||
|
||||
| Colonne affichée | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) |
|
||||
|---|---|---|---|
|
||||
| Numéro | `number` ∈ `weighing_ticket:read` | ✅ | — |
|
||||
| Client | `client` ∈ `weighing_ticket:read` (embed) | ✅ | `client:read` ✅ (RG-5.03) |
|
||||
| Fournisseur | `supplier` ∈ `weighing_ticket:read` (embed) | ✅ | `supplier:read` ✅ |
|
||||
| Autre | `otherLabel` ∈ `weighing_ticket:read` | ✅ | — |
|
||||
| Date | `fullDate` ?? `emptyDate` (date du ticket) ∈ `weighing_ticket:read` | ✅ | — |
|
||||
| Poids | `netWeight` ∈ `weighing_ticket:read` | ✅ | — |
|
||||
|
||||
> **Note « Date » liste** : on expose une propriété calculée `displayDate` (getter) = `fullDate ?? emptyDate`, dans `weighing_ticket:read` (les `empty/full*` détaillées restent en `:item:read`).
|
||||
|
||||
**DÉTAIL — maillons** : scalaires + `emptyDate/emptyWeight/emptyDsd/...` + `full*` ∈ `weighing_ticket:item:read` ; `client`/`supplier`/`site` embarqués (`client:read`/`supplier:read`/`site:read`).
|
||||
|
||||
### 4.0.bis Réponse JSON de référence (DoD — à CAPTURER sur l'API réelle)
|
||||
|
||||
> **Definition of Done** (miroir M2/M3/M4) : avant les écrans front, **capturer la réponse RÉELLE** via un test PHPUnit (`WeighingTicketSerializationContractTest`, ticket complet seedé : contrepartie Client, pesée vide + plein) et la coller ici. Toute donnée affichée par le front DOIT apparaître dans ce JSON.
|
||||
>
|
||||
> **Pièges à re-tester** :
|
||||
> 1. `client` / `supplier` doivent sortir en **objet embarqué**, pas en IRI nu → read-groups `client:read`/`supplier:read`.
|
||||
> 2. Booléen `plateFreeFormat` : clé présente (piège #3 M1 → getter + `SerializedName` si besoin).
|
||||
> 3. `number` présent et formaté `{siteCode}-TP-{NNNN}`.
|
||||
> 4. `netWeight` cohérent = `full - empty` (plein − vide, RG-5.05).
|
||||
|
||||
**`GET /api/weighing_tickets` (LISTE)** — enveloppe Hydra AP4 (`member`/`totalItems`/`view`), filtrée site courant (§ 2.3) :
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"@context": "/api/contexts/WeighingTicket",
|
||||
"@id": "/api/weighing_tickets",
|
||||
"@type": "Collection",
|
||||
"totalItems": 1,
|
||||
"member": [
|
||||
{
|
||||
"@id": "/api/weighing_tickets/1",
|
||||
"@type": "WeighingTicket",
|
||||
"id": 1,
|
||||
"number": "86-TP-0001",
|
||||
"counterpartyType": "CLIENT",
|
||||
"client": { "@id": "/api/clients/117", "@type": "Client", "id": 117, "companyName": "NÉGOCE MÉTAUX ATLANTIQUE" },
|
||||
"supplier": null,
|
||||
"otherLabel": null,
|
||||
"displayDate": "2026-06-17T09:12:00+02:00",
|
||||
"netWeight": 12340,
|
||||
"plateFreeFormat": false,
|
||||
"createdAt": "2026-06-17T09:12:00+02:00",
|
||||
"updatedAt": "2026-06-17T09:12:00+02:00"
|
||||
}
|
||||
],
|
||||
"view": { "@id": "/api/weighing_tickets", "@type": "PartialCollectionView" }
|
||||
}
|
||||
```
|
||||
|
||||
**`GET /api/weighing_tickets/{id}` (DÉTAIL)** — ajoute les pesées :
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"@id": "/api/weighing_tickets/1",
|
||||
"@type": "WeighingTicket",
|
||||
"id": 1,
|
||||
"number": "86-TP-0001",
|
||||
"site": { "@id": "/api/sites/1", "@type": "Site", "id": 1, "name": "Châtellerault", "code": "86" },
|
||||
"counterpartyType": "CLIENT",
|
||||
"client": { "@id": "/api/clients/117", "@type": "Client", "id": 117, "companyName": "NÉGOCE MÉTAUX ATLANTIQUE" },
|
||||
"immatriculation": "AB-123-CD",
|
||||
"plateFreeFormat": false,
|
||||
"emptyDate": "2026-06-17T09:00:00+02:00", "emptyWeight": 14660, "emptyDsd": 41, "emptyMode": "AUTO", "emptyManualNumber": null,
|
||||
"fullDate": "2026-06-17T09:12:00+02:00", "fullWeight": 27000, "fullDsd": 42, "fullMode": "AUTO", "fullManualNumber": null,
|
||||
"netWeight": 12340
|
||||
}
|
||||
```
|
||||
|
||||
### 4.1 Query params (LISTE)
|
||||
|
||||
| Param | Effet |
|
||||
|---|---|
|
||||
| `?page` / `?itemsPerPage` | pagination standard (10 / 25 / 50, défaut 10) |
|
||||
| `?search=` | recherche sur `number`, nom client/fournisseur, `other_label`, `immatriculation` |
|
||||
| `?order[displayDate]=desc` | tri par date (défaut : `number DESC` = plus récents en tête) |
|
||||
| *(site courant)* | filtré automatiquement par `SiteScopedQueryExtension` (§ 2.3) |
|
||||
|
||||
Pagination obligatoire (règle ABSOLUE n°13) — provider ORM via `ApiPlatform\Doctrine\Orm\Paginator`, jamais d'array brut.
|
||||
|
||||
### 4.2 Endpoint pesée (pont bascule) — `POST /api/weighbridge_readings`
|
||||
|
||||
Action **autonome** (le ticket n'est pas encore créé quand on déclenche la pesée du formulaire principal).
|
||||
|
||||
- **Sécurité** : `is_granted('logistique.weighing_tickets.manage')`.
|
||||
- **AUTO (pesée bascule)** — body `{ "mode": "AUTO" }` → le site courant est résolu serveur (`CurrentSiteProviderInterface`).
|
||||
- Réponse `200` : `{ "weight": 23187, "dsd": 42, "mode": "AUTO" }` (stub : `weight = random_int(10000,50000)`, `dsd = nextDsd(site)`).
|
||||
- Réponse `503` (RG-5.06) si `WeighbridgeUnavailableException` : `{ "title": "Pont bascule indisponible", "detail": "Passez en pesée manuelle." }`.
|
||||
- **MANUAL (pesée manuelle)** — body `{ "mode": "MANUAL", "weight": 23187, "manualNumber": "PAP-555" }`.
|
||||
- Réponse `200` : `{ "weight": 23187, "dsd": 43, "manualNumber": "PAP-555", "mode": "MANUAL" }` (`dsd = dernier dsd du site + 1`, RG-5.04).
|
||||
|
||||
> **Implémentation** : `#[ApiResource]` non-Doctrine (DTO `WeighbridgeReadingInput`/`Output`) + Processor dédié, OU une ressource `WeighbridgeReading` virtuelle. **Pas de controller** Symfony (règle backend). Le Processor appelle `WeighbridgeReaderInterface` + le `DsdAllocator` (verrou `FOR UPDATE`).
|
||||
>
|
||||
> **Concurrence DSD** : le `dsd` renvoyé ici est **prévisionnel**. L'attribution **autoritaire** du `dsd` (et du `number`) est refaite/verrouillée à la **création du ticket** (`POST /api/weighing_tickets`) pour éviter les collisions si deux postes pèsent en parallèle. Front : afficher le dsd renvoyé, mais c'est le ticket persisté qui fait foi.
|
||||
|
||||
### 4.3 `POST /api/weighing_tickets` (création)
|
||||
|
||||
- Le client envoie : `counterpartyType` (+ `client`/`supplier`/`otherLabel`), `immatriculation`, `plateFreeFormat`, et les pesées (`emptyDate/Weight/Dsd/Mode/ManualNumber`, `full*`).
|
||||
- Le **Processor** :
|
||||
1. Résout le **site courant** (`CurrentSiteProviderInterface`) → `site_id`.
|
||||
2. Attribue le **numéro** `{siteCode}-TP-{NNNN}` (compteur verrouillé — RG-5.02).
|
||||
3. (Re)attribue les `dsd` autoritaires si nécessaire (verrou — RG-5.04).
|
||||
4. Normalise `immatriculation` (RG-5.01) ; valide la cohérence contrepartie (RG-5.03) et pesées.
|
||||
5. Calcule `net_weight = full_weight - empty_weight` si les deux poids sont présents (RG-5.05).
|
||||
- Réponse `201` avec le ticket complet → le front ouvre la **modal d'impression** (RG-5.08).
|
||||
|
||||
### 4.4 `PATCH /api/weighing_tickets/{id}` (modification)
|
||||
|
||||
- Mise à jour partielle (mêmes règles). Le **numéro et le site sont immuables** (ignorés s'ils sont envoyés). `net_weight` recalculé. Le bouton d'impression est disponible (RG-5.08).
|
||||
|
||||
### 4.5 Export — `GET /api/weighing_tickets/export.xlsx`
|
||||
|
||||
- Exporte **toute la liste** des tickets (docx : bouton « Exporter » → « Exporte toute la liste des tickets de pesée »), filtrée par le site courant + filtres actifs.
|
||||
- Colonnes : Numéro, Contrepartie (Client/Fournisseur/Autre + nom), Date, Immatriculation, Poids vide, Poids plein, **Poids net**, DSD vide/plein.
|
||||
- Génération via le helper XLSX standard projet (skill `xlsx`). Endpoint : provider dédié renvoyant un binaire (`Content-Type` xlsx) — whitelisté pagination (`EXCLUDED`) car export complet.
|
||||
|
||||
## 5. RBAC, module & sidebar
|
||||
|
||||
### 5.1 `LogistiqueModule::permissions()`
|
||||
|
||||
```php
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'logistique.weighing_tickets.view', 'label' => 'Voir les tickets de pesée'],
|
||||
['code' => 'logistique.weighing_tickets.manage', 'label' => 'Créer / modifier les tickets de pesée'],
|
||||
];
|
||||
}
|
||||
```
|
||||
Synchronisation : `app:sync-permissions`.
|
||||
|
||||
### 5.2 Matrice rôle → permissions (docx p.3)
|
||||
|
||||
| Rôle | `…view` | `…manage` |
|
||||
|---|:--:|:--:|
|
||||
| **Admin** | ✅ | ✅ |
|
||||
| **Bureau** | ✅ | ✅ |
|
||||
| **Usine** | ✅ | ✅ |
|
||||
| **Compta** | ❌ | ❌ |
|
||||
| **Commerciale** | ❌ | ❌ |
|
||||
|
||||
> ⚠ **Changement vs M5 V0.1** : en V0.2 **Usine = Tout / Tout** (consultation + ajout/modif), alors que la V0.1 disait « Oui ». Compta et Commerciale = **aucun** accès (item sidebar masqué).
|
||||
|
||||
### 5.3 Sidebar (`config/sidebar.php`)
|
||||
|
||||
Nouvelle section **« Logistique »** (ou item rattaché à une section logistique mutualisée avec Transport — à confirmer). Item :
|
||||
|
||||
```php
|
||||
[
|
||||
'label' => 'sidebar.logistique.weighing_tickets',
|
||||
'to' => '/weighing-tickets',
|
||||
'icon' => 'mdi-scale',
|
||||
'module' => 'logistique',
|
||||
'permission' => 'logistique.weighing_tickets.view',
|
||||
],
|
||||
```
|
||||
|
||||
### 5.4 Règle ABSOLUE n°8 — 3 miroirs RBAC
|
||||
|
||||
Toute permission `logistique.*` doit être posée **simultanément** dans :
|
||||
1. `config/sidebar.php` (item + permission ci-dessus),
|
||||
2. `frontend/tests/e2e/_fixtures/personas.ts` (ajuster un persona existant : Usine gagne `weighing_tickets.view/manage` + `expectedAdminLinks`),
|
||||
3. `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` (miroir back du même persona).
|
||||
|
||||
## 6. Normalisation serveur (RG-5.01 / RG-5.10)
|
||||
|
||||
`WeighingTicketFieldNormalizer` (miroir `CarrierFieldNormalizer`), appelé par le Processor avant validation :
|
||||
|
||||
```php
|
||||
final class WeighingTicketFieldNormalizer
|
||||
{
|
||||
// RG-5.01 : trim + UPPER ; si !plateFreeFormat → reformate XX-000-XX (rejet 422 si invalide).
|
||||
public function normalizeImmatriculation(?string $v, bool $freeFormat): ?string
|
||||
public function normalizeOtherLabel(?string $v): ?string // trim
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Règles de gestion (RG)
|
||||
|
||||
| RG | Source | Énoncé |
|
||||
|---|---|---|
|
||||
| **RG-5.01** | docx | Immatriculation : masque par défaut `XX-000-XX` ; « Tout format » coché → masque désactivé (saisie libre). Les champs `immatriculation` et `plateFreeFormat` sont **connectés entre les 2 formulaires** (une seule valeur portée par le ticket — § 2.10). |
|
||||
| **RG-5.02** | back | Numéro `{siteCode}-TP-{NNNN}`, **unique par site**, attribué serveur à la création, immuable. Séquence verrouillée par site (§ 2.5). |
|
||||
| **RG-5.03** | docx+back | Contrepartie `CLIENT`/`FOURNISSEUR`/`AUTRE` → champ associé obligatoire, les autres forcés nuls (§ 2.9). |
|
||||
| **RG-5.04** | docx+back | DSD = compteur de pesée du pont, par site. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1. « Numéro de pesée » manuel = champ distinct (§ 2.7). |
|
||||
| **RG-5.05** | back | Poids net = `poids plein − poids vide`, calculé serveur, exposé en liste/détail (§ 2.8 — confirmé Matthieu 17/06). |
|
||||
| **RG-5.06** | docx+back | Pesée bascule indisponible → erreur explicite + bascule en pesée manuelle. Au M5, le pont est un **stub** (poids aléatoire ∈ [10000,50000] kg, § 2.6). |
|
||||
| **RG-5.07** | docx | Formulaire à vide : `Date` = date du jour par défaut ; `Poids` et `DSD` **readonly** (remplis par la pesée, pas saisis). |
|
||||
| **RG-5.08** | docx | « Valider » (création) → enregistre + ouvre la modal d'impression. En modification : bouton « Valider » → « Enregistrer », bouton d'impression disponible (absent à l'ajout). Le bouton « Enregistré » du bloc pesée à vide disparaît en modification. **Le bon d'impression est réalisé par Tristan** (§ 2.12). |
|
||||
| **RG-5.09** | back | Site & numéro immuables après création ; liste cloisonnée par site courant (§ 2.3, à confirmer). |
|
||||
| **RG-5.10** | back | Normalisation immatriculation (trim/UPPER/format) côté serveur (§ 6). |
|
||||
|
||||
Cohérence inter-champs (RG-5.03, RG-5.01) implémentée via `#[Assert\Callback]` portant des messages FR + CHECK Postgres en garde-fou (§ 3.2).
|
||||
|
||||
## 8. Tests (PHPUnit) — `make test`
|
||||
|
||||
- **`WeighingTicketSerializationContractTest`** : capture JSON liste + détail (DoD § 4.0.bis), 4 pièges verts.
|
||||
- **`WeighingTicketNumberingTest`** : `{siteCode}-TP-{NNNN}`, séquence par site, unicité, concurrence (FOR UPDATE).
|
||||
- **`DsdAllocatorTest`** : AUTO incrémente ; MANUAL = dernier + 1 ; par site.
|
||||
- **`WeighbridgeReaderStubTest`** : poids ∈ [10000,50000] ; chemin d'erreur `WeighbridgeUnavailableException` → 503 (RG-5.06).
|
||||
- **`NetWeightTest`** : `plein − vide` ; null si une pesée manque (RG-5.05).
|
||||
- **`CounterpartyValidationTest`** : RG-5.03 (chaque branche + rejets).
|
||||
- **`ImmatriculationNormalizationTest`** : masque XX-000-XX, free format, 422 (RG-5.01).
|
||||
- **RBAC** : Usine/Bureau/Admin OK ; Compta/Commerciale 403.
|
||||
- **Architecture** (déjà en place, ne pas casser) : `ColumnsHaveSqlCommentTest`, `EntitiesAreTimestampableBlamableTest`, `AuditableEntitiesHaveI18nLabelTest`, `CollectionsArePaginatedTest`, `EntityConstraintsHaveFrenchMessageTest`.
|
||||
|
||||
## 9. Hors périmètre (HP)
|
||||
|
||||
| Réf | Sujet |
|
||||
|---|---|
|
||||
| HP-M5-01 | Vue multi-sites des tickets (retirer le cloisonnement + filtre `?siteId=`) si demandé (§ 2.3). |
|
||||
| HP-M5-02 | Driver matériel réel du pont bascule (protocole série/TCP, parsing trame, reconnexion) derrière `WeighbridgeReaderInterface` (§ 2.6). |
|
||||
| HP-M5-03 | Sens réception-expédition explicite + contrôle de signe du net (le net reste `plein − vide`, § 2.8). |
|
||||
| HP-M5-04 | Génération PDF serveur du ticket (`/print.pdf`) si l'impression navigateur ne suffit pas (§ 2.12). |
|
||||
| HP-M5-05 | Archivage fonctionnel des tickets (non prévu au docx — § 2.13). |
|
||||
|
||||
## 10. Tickets Lesstime (à découper — back en tête)
|
||||
|
||||
| Ordre | Sujet | Tag |
|
||||
|---|---|---|
|
||||
| 0 | Scaffolding module `Logistique` (create-module) + `config/modules.php` + sidebar + 3 miroirs RBAC | Backend |
|
||||
| 1 | Migration : `site.code` + compteurs + `weighing_ticket` (+ index + COMMENT) | Backend |
|
||||
| 2 | Entité `WeighingTicket` + Repository + contrat sérialisation | Backend |
|
||||
| 3 | `WeighbridgeReaderInterface` + `RandomWeighbridgeReader` + `DsdAllocator` + endpoint `weighbridge_readings` | Backend |
|
||||
| 4 | `WeighingTicketProvider` + `WeighingTicketProcessor` (numérotation, RG-5.03/5.05, normalisation) | Backend |
|
||||
| 5 | Export XLSX | Backend |
|
||||
| 6 | Tests PHPUnit RG-5.01→5.10 + capture contrat JSON | Backend |
|
||||
| 7 | Page liste `/weighing-tickets` (usePaginatedList) + export | Frontend |
|
||||
| 8 | Écran Ajouter (formulaires vide + plein, pesée bascule/manuelle, masque immat) | Frontend |
|
||||
| 9 | Écran Modification (la **modal/bon d'impression** = **Tristan**, § 2.12) | Frontend |
|
||||
| 10 | i18n + libellé audit + branchement site courant | Frontend |
|
||||
| — | **Bon d'impression du ticket de pesée** | **Tristan (hors découpe M5)** |
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M5
|
||||
nom: "Tickets de pesée"
|
||||
ecran: tickets-pesee
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0.1
|
||||
date_redaction: 2026-06-17
|
||||
# Historique :
|
||||
# V0.1 (2026-06-17) — Restitution Markdown du docx « M5-ticket-de-pesee-V02 » (V0.2, 15/06/2026,
|
||||
# validation client en attente) + maquette Figma (node 1322-16774). Précisions techniques (back)
|
||||
# dans spec-back.md. Réutilise le pattern et les composants M1/M2/M3/M4.
|
||||
# Maquette : les 2 blocs (vide + plein) portent « Pesée bascule » + « Pesée manuelle » ;
|
||||
# contrepartie portée par le bloc « Poids à vide » ; net = plein − vide (confirmé Matthieu).
|
||||
|
||||
# === LIENS ===
|
||||
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1322-16774&p=f&m=dev"
|
||||
regles_metier: [RG-5.01, RG-5.02, RG-5.03, RG-5.04, RG-5.05, RG-5.06, RG-5.07, RG-5.08, RG-5.09, RG-5.10]
|
||||
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||
lien_spec_back: ./spec-back.md
|
||||
|
||||
# === VALIDATION CLIENT ===
|
||||
client_validation_1:
|
||||
statut: validee
|
||||
version: V0.2
|
||||
date_doc: 2026-06-15
|
||||
date_validation: 2026-06-17
|
||||
valide_par: "Matthieu (CP MALIO)"
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_project_id: 6
|
||||
lesstime_taskgroup_id: 33 # M5 — Tickets de pesée (ERP-181 → ERP-192)
|
||||
statut_global: pret_a_dev
|
||||
---
|
||||
|
||||
# Module 5 — Tickets de pesée (V0.1 front)
|
||||
|
||||
> **Origine** : spec fonctionnelle `M5-ticket-de-pesee-V02` (V0.2, 15/06/2026, **validation client en attente**) + maquette Figma (node 1322-16774). Restitution Markdown pour intégration au workflow MALIO. Toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M5 réutilise le pattern et les composants posés aux [M1 clients](../M1-clients/spec-front.md) → [M4 transporteurs](../M4-transporteurs/spec-front.md).
|
||||
|
||||
> **Nouveau module `Logistique`** (DÉCISION Matthieu 17/06). La maquette montre une section sidebar **Logistique** plus large (Réception, Expédition, Validations, Triage, **Ticket de pesée**, Bons…) ; **le M5 ne livre que l'écran « Ticket de pesée »**. Les autres items sont hors périmètre (modules/écrans ultérieurs).
|
||||
|
||||
> **Décisions (17/06)** : (1) **pont bascule = stub** renvoyant un poids aléatoire ∈ [10000, 50000] kg (pas de liaison matérielle — [`spec-back.md § 2.6`](./spec-back.md)) ; (2) **DSD = compteur de pesée** par site, +1 par pesée ([`§ 2.7`](./spec-back.md)) ; (3) **net = plein − vide** ([`§ 2.8`](./spec-back.md)) ; (4) numéro **`{siteCode}-TP-{NNNN}` par site** ([`§ 2.5`](./spec-back.md)).
|
||||
|
||||
## But
|
||||
|
||||
Lister les tickets de pesée et accéder à leur fiche : consultation, création (pesée à vide + pesée à plein au pont bascule), modification, impression. Chaque ticket porte un **numéro unique par site** (ex. `86-TP-0001`) et une **contrepartie** Client / Fournisseur / Autre.
|
||||
|
||||
## Accès
|
||||
|
||||
- **Depuis** : menu principal → section **Logistique** → item **« Ticket de pesée »** (route `/weighing-tickets`).
|
||||
- **Site** : l'écran dépend du **site courant** (sélecteur de site en haut de l'app — onglets `CHÂTELLERAULT` / `SAINT-JEAN` / `POMMEVIC`). Le site pilote la numérotation et (par défaut) le cloisonnement de la liste ([`spec-back.md § 2.3 / § 2.5`](./spec-back.md)).
|
||||
- **Rôles autorisés** (tableau « Rôles & permissions » du docx p.3, V0.2) :
|
||||
|
||||
| Rôle | Consultation | Ajout / Modification |
|
||||
|---|---|---|
|
||||
| **Admin** | ✅ Tout | ✅ Tout |
|
||||
| **Bureau** | ✅ Tout | ✅ Tout |
|
||||
| **Usine** | ✅ Tout | ✅ Tout |
|
||||
| **Compta** | ❌ | ❌ |
|
||||
| **Commerciale** | ❌ | ❌ |
|
||||
|
||||
> **Notes** :
|
||||
> - RBAC transposée sur `logistique.weighing_tickets.view` / `.manage` ([`spec-back.md § 5`](./spec-back.md)).
|
||||
> - ⚠ **Changement vs M5 V0.1** : en **V0.2, Usine = Tout / Tout**. **Compta** et **Commerciale** n'ont **aucun** accès (item sidebar masqué).
|
||||
|
||||
## Navigation
|
||||
|
||||
Page d'entrée de l'écran : **datatable** « Tickets de pesées ».
|
||||
|
||||
- **Clic sur une ligne** → écran **Modification d'un ticket de pesée** (le docx ne prévoit pas d'écran de consultation séparé — clic = édition).
|
||||
- **Bouton « + Ajouter »** (haut droite, si `manage`) → écran **Ajouter un ticket de pesée**.
|
||||
- **Bouton « Exporter »** (bas de liste, maquette) → télécharge un **XLSX** de **toute la liste** (filtres + site courant appliqués). Format dans [`spec-back.md § 4.5`](./spec-back.md).
|
||||
|
||||
## Datatable des tickets
|
||||
|
||||
Composant : `<MalioDataTable>` branché sur `usePaginatedList<WeighingTicket>({ url: '/weighing_tickets' })` *(URL API en `snake_case` ; la route Nuxt reste `/weighing-tickets`)* (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes (docx p.3 + maquette) :
|
||||
|
||||
| Colonne | Source | Tri |
|
||||
|---|---|---|
|
||||
| **Numéro** | `ticket.number` (`{siteCode}-TP-{NNNN}`) | DESC par défaut (plus récents en tête) |
|
||||
| **Client** | `ticket.client.companyName` (vide si contrepartie ≠ Client) | Non |
|
||||
| **Fournisseur** | `ticket.supplier.companyName` (vide si ≠ Fournisseur) | Non |
|
||||
| **Autre** | `ticket.otherLabel` (vide si ≠ Autre) | Non |
|
||||
| **Date** | `ticket.displayDate` (`fullDate ?? emptyDate`, format `JJ-MM-AAAA`) | Oui |
|
||||
| **Poids** | `ticket.netWeight` (kg, = plein − vide — RG-5.05) | Oui |
|
||||
|
||||
> **Clic ligne** → écran Modification. **Pagination** : standard Starseed 10 / 25 / 50 (défaut 10). Liste **cloisonnée par site courant** par défaut ([`spec-back.md § 2.3`](./spec-back.md)).
|
||||
|
||||
## Écran « Ajouter un ticket de pesée »
|
||||
|
||||
**Accès** : bouton « + Ajouter ». **Rôles** : Admin, Bureau, Usine.
|
||||
**Titre** : « ← Ticket de pesée » (flèche retour vers la liste).
|
||||
|
||||
L'écran (maquette) est composé de **deux blocs empilés** — **« Poids à vide »** puis **« Poids à plein »** — et d'un bouton **« Valider »** en bas.
|
||||
|
||||
### Bloc « Poids à vide »
|
||||
|
||||
Boutons en haut à droite du bloc : **« Pesée bascule »** (`<MalioButton>` secondaire) + **« Pesée manuelle »** (`<MalioButton>` primaire).
|
||||
|
||||
**Champs** :
|
||||
|
||||
| Champ | Type composant | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Fournisseur / Client / Autre** | `<MalioSelect>` (3 valeurs) | Oui | RG-5.03 — pilote le champ suivant |
|
||||
| **Nom du fournisseur** | `<MalioSelect>` (liste fournisseurs M2) | Conditionnel | RG-5.03 — visible + obligatoire si « Fournisseur » |
|
||||
| **Nom du client** | `<MalioSelect>` (liste clients M1) | Conditionnel | RG-5.03 — visible + obligatoire si « Client » |
|
||||
| **Autre** | `<MalioInputText>` | Conditionnel | RG-5.03 — visible + obligatoire si « Autre » |
|
||||
| **Date** | `<MalioInputText>` type `date` *(cf. note)* | Oui | RG-5.07 — **date du jour par défaut** |
|
||||
| **Poids** | `<MalioInputNumber>` (suffixe « Kg ») | Oui | RG-5.07 — **readonly**, rempli par la pesée |
|
||||
| **DSD** | `<MalioInputNumber>` | Oui | RG-5.04 / RG-5.07 — **readonly**, rempli par la pesée |
|
||||
| **Immatriculation** | `<MalioInputText>` (masque `XX-000-XX`) | Oui | RG-5.01 |
|
||||
| **Tout format** | `<MalioCheckbox>` | Non | RG-5.01 — désactive le masque |
|
||||
|
||||
> **La contrepartie (Fournisseur/Client/Autre) + son champ associé est portée par le bloc « Poids à vide » uniquement** (maquette) — c'est une donnée du ticket, pas répétée sur le bloc plein. Côté back : champs `counterpartyType` / `client` / `supplier` / `otherLabel` du ticket ([`spec-back.md § 2.9`](./spec-back.md)).
|
||||
|
||||
**Action « Enregistrer »** (sous le bloc, maquette) : POST `/api/weighing_tickets` (création initiale du ticket avec la pesée à vide) — [`spec-back.md § 4.3`](./spec-back.md). Le numéro `{siteCode}-TP-{NNNN}` est attribué serveur.
|
||||
|
||||
### Bloc « Poids à plein »
|
||||
|
||||
Mêmes boutons **« Pesée bascule »** + **« Pesée manuelle »**. **Champs** : Date (date du jour par défaut), Poids (readonly, Kg), DSD (readonly), Immatriculation (`XX-000-XX`), « Tout format ».
|
||||
|
||||
> **Immatriculation + « Tout format » connectés entre les 2 blocs** (RG-5.01) : une seule valeur partagée — modifier l'un met à jour l'autre (même véhicule). Géré dans `useWeighingTicketForm()` (état partagé).
|
||||
|
||||
### Boutons de pesée — comportement
|
||||
|
||||
| Bouton | Déclencheur | Comportement |
|
||||
|---|---|---|
|
||||
| **Pesée bascule** | clic | Ouvre une **modal de confirmation** « Êtes-vous sûr de vouloir déclencher une pesée ? » (`<MalioButton>` « Valider »). Si confirmé → `POST /api/weighbridge_readings { mode: 'AUTO' }` ([`spec-back.md § 4.2`](./spec-back.md)) → remplit **Poids** et **DSD** du bloc, ferme la modal. **En cas d'erreur** (RG-5.06) : le message d'erreur s'affiche **dans la modal** et invite à passer en **pesée manuelle**. *(Au M5, le stub renvoie toujours un poids ∈ [10000,50000] — le chemin d'erreur est néanmoins géré.)* |
|
||||
| **Pesée manuelle** | clic | Ouvre une **modal « Pesée manuelle »** avec **Poids** et **Numéro de pesée** à saisir (`<MalioInputNumber>` + `<MalioInputText>`), bouton « Enregistrer ». Une fois validé → le **Poids** du bloc est rempli ; le **DSD** est **calculé automatiquement** = dernier dsd du site + 1 (`POST /api/weighbridge_readings { mode: 'MANUAL', weight, manualNumber }` — RG-5.04). |
|
||||
|
||||
### Action « Valider » (bas d'écran)
|
||||
|
||||
`<MalioButton>` « Valider » → finalise le ticket (PATCH `/api/weighing_tickets/{id}` avec la pesée à plein + recalcul du net — [`spec-back.md § 4.4`](./spec-back.md)) puis **ouvre la modal d'impression** du ticket (RG-5.08 — **bon d'impression réalisé par Tristan**, cf. § Modales).
|
||||
|
||||
## Écran « Modification d'un ticket de pesée »
|
||||
|
||||
**But** : modifier un ticket existant et/ou **imprimer** le ticket.
|
||||
**Accès** : clic sur une ligne de la liste. **Rôles** : Admin, Bureau, Usine.
|
||||
|
||||
**Identique à l'écran d'ajout** — mêmes 2 blocs, mêmes règles (RG-5.01 → RG-5.10) — **sauf** (docx + maquette) :
|
||||
- Les champs sont **pré-remplis** avec les valeurs actuelles.
|
||||
- Le **bouton « Enregistrer » du bloc « Poids à vide » disparaît** (RG-5.08) — on enregistre via le bas d'écran.
|
||||
- En bas : **« Enregistrer »** (remplace « Valider ») + **« Imprimer »** (bouton d'impression **absent à l'ajout**, RG-5.08).
|
||||
- Le numéro et le site sont **immuables** (lecture seule).
|
||||
|
||||
## Modales
|
||||
|
||||
| Modale | Contenu | Source |
|
||||
|---|---|---|
|
||||
| **Confirmation pesée bascule** | « Êtes-vous sûr de vouloir déclencher une pesée ? » + bouton « Valider ». Erreur affichée inline → invite pesée manuelle (RG-5.06). | docx p.5 + maquette |
|
||||
| **Pesée manuelle** | Champs « Poids » + « Numéro de pesée » + bouton « Enregistrer ». DSD auto = dernier +1 (RG-5.04). | docx p.5 + maquette |
|
||||
| **Impression du ticket / bon de pesée** | Aperçu imprimable du ticket (numéro, contrepartie, immat, pesée vide/plein, net, DSD, date). **Réalisé par Tristan** (voir encadré ci-dessous). | docx p.5 / RG-5.08 ; [`spec-back.md § 2.12`](./spec-back.md) |
|
||||
|
||||
> **⚠ Bon d'impression = Tristan.** La conception et la réalisation du **bon d'impression** (gabarit du ticket de pesée, mise en page, déclenchement) sont **prises en charge par Tristan lui-même**, hors de la découpe front standard du M5. Le reste de l'écran (modale de confirmation, modale pesée manuelle, formulaires) reste dans la découpe M5.
|
||||
> - **Déclencheur attendu** : modale d'impression à la **validation** (création) ; bouton **« Imprimer »** en **modification** (absent à l'ajout — RG-5.08).
|
||||
> - **Données disponibles** : toute la réponse `GET /api/weighing_tickets/{id}` (numéro, site, contrepartie, immat, pesées vide/plein, net, DSD, dates) — [`spec-back.md § 2.12 / § 4.0`](./spec-back.md).
|
||||
> - **Modales** : réutiliser le wrapper de modal partagé `frontend/shared/` (comme M1→M4).
|
||||
|
||||
## Composants UI à utiliser (`@malio/layer-ui`)
|
||||
|
||||
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
|
||||
- **Select** : `<MalioSelect>` (contrepartie, nom client, nom fournisseur)
|
||||
- **Input texte** : `<MalioInputText>` (Autre, Immatriculation, Numéro de pesée)
|
||||
- **Input nombre** : `<MalioInputNumber>` (Poids, DSD)
|
||||
- **Checkbox** : `<MalioCheckbox>` (« Tout format »)
|
||||
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>` (Pesée bascule, Pesée manuelle, Valider, Enregistrer, Imprimer, + Ajouter, Exporter)
|
||||
- **Validation par champ** : `useFormErrors` (mapping 422 inline — règle frontend obligatoire)
|
||||
- **Toasts** : standards via `useApi()`
|
||||
|
||||
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
|
||||
- **Date** : `<MalioInput>` ne couvrant pas `date` nativement, utiliser un `<input type="date">` encapsulé OU `MalioDate` si dispo (cf. exceptions @.claude/rules/frontend.md — type `date` explicitement listé comme exception tolérée).
|
||||
- **Masque immatriculation `XX-000-XX`** : si non couvert par `<MalioInputText>`, masque local (directive) + `// TODO`. La validation de format reste **autoritaire côté serveur** (RG-5.01 / RG-5.10).
|
||||
- **Modales** : wrapper partagé `frontend/shared/`.
|
||||
|
||||
## Composables & appels API
|
||||
|
||||
- `usePaginatedList<WeighingTicket>({ url: '/weighing_tickets' })` — liste paginée (obligatoire). Consomme `number`, `client`/`supplier`/`otherLabel`, `displayDate`, `netWeight` ([`spec-back.md § 4.0`](./spec-back.md)).
|
||||
- `useWeighingTicket(id)` — charge le détail via `GET /api/weighing_tickets/{id}` (pesées vide + plein embarquées, client/supplier/site imbriqués). **DoD avant intégration** : vérifier le JSON réel ([`spec-back.md § 4.0.bis`](./spec-back.md)).
|
||||
- `useWeighingTicketForm()` — workflow 2 blocs (POST à l'« Enregistrer » du bloc vide, PATCH au « Valider ») + **état partagé** immatriculation/« Tout format » entre les 2 blocs (RG-5.01) + gestion des champs conditionnels de contrepartie (RG-5.03).
|
||||
- `useWeighbridge()` — déclenche la pesée : `POST /api/weighbridge_readings` (AUTO ou MANUAL), gère la modal de confirmation et le chemin d'erreur → pesée manuelle (RG-5.06).
|
||||
- `useClientOptions()` / `useSupplierOptions()` — alimentent les selects (référentiels M1/M2 via `?pagination=false` — échappatoire selects).
|
||||
- `useCurrentSite()` — site courant (sélecteur) — déjà exposé côté front (Sites). Le back lit le site courant pour la numérotation ; le front n'a pas à l'envoyer.
|
||||
- `usePermissions()` — masque l'item sidebar et les boutons selon `logistique.weighing_tickets.view/manage`.
|
||||
- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4).
|
||||
|
||||
## Règles de formatage et normalisation
|
||||
|
||||
Le serveur normalise systématiquement ([`spec-back.md § 6`](./spec-back.md)) :
|
||||
|
||||
| Champ | Normalisation serveur | Affichage front |
|
||||
|---|---|---|
|
||||
| Immatriculation | trim + UPPER ; format `XX-000-XX` sauf « Tout format » (RG-5.01) | UPPER, masqué |
|
||||
| Autre (`otherLabel`) | trim | identique |
|
||||
| Poids / DSD | entiers (kg) | « 7 150 Kg », DSD brut |
|
||||
| Numéro de ticket | `{siteCode}-TP-{NNNN}` (serveur) | affiché tel quel |
|
||||
|
||||
> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur que l'UI affiche.
|
||||
|
||||
## Différences notables avec les modules précédents
|
||||
|
||||
| Zone | M1→M4 | M5 tickets de pesée |
|
||||
|---|---|---|
|
||||
| Module | Commercial / Transport… | **Logistique** (nouveau, ERP à venir) |
|
||||
| Saisie poids | — | **Pesée au pont bascule** (stub random) + pesée manuelle |
|
||||
| Cloisonnement par site | M3 oui / M4 non | **Oui** (site courant) + numéro par site |
|
||||
| Numérotation métier | id technique | **`{siteCode}-TP-{NNNN}`** par site (RG-5.02) |
|
||||
| Onglets | présents | **Aucun onglet** : 2 blocs empilés (vide + plein) |
|
||||
| Impression | aucune | **Modal d'impression** du ticket (RG-5.08) |
|
||||
| Contrepartie | — | **Client / Fournisseur / Autre** (conditionnel, RG-5.03) |
|
||||
|
||||
## Points résolus côté back
|
||||
|
||||
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|
||||
|---|---|---|
|
||||
| 1 | Module | **Nouveau module `Logistique`** (§ 2.1) |
|
||||
| 2 | Pont bascule | **Stub** poids aléatoire ∈ [10000,50000], interface réutilisable, driver réel HP (§ 2.6) |
|
||||
| 3 | DSD | **Compteur de pesée par site**, +1 par pesée ; manuel = dernier +1 (§ 2.7) |
|
||||
| 4 | Poids net | **plein − vide**, calculé serveur (§ 2.8) |
|
||||
| 5 | Numérotation | **`{siteCode}-TP-{NNNN}`** par site, séquence verrouillée (§ 2.5) ; ajout `site.code` |
|
||||
| 6 | Contrepartie | `counterpartyType` + FK Client/Supplier ou `otherLabel` (RG-5.03, § 2.9) |
|
||||
| 7 | Deux pesées | Colonnes plates `empty_*` / `full_*` ; les 2 blocs supportent bascule + manuelle (§ 2.4) |
|
||||
| 8 | Impression | Modal d'impression front ; bouton dispo en modif seulement (RG-5.08, § 2.12) |
|
||||
| 9 | Masque immat | `XX-000-XX` + « Tout format », connectés entre blocs (RG-5.01, § 2.10) |
|
||||
| 10 | RBAC | `logistique.weighing_tickets.view/manage` ; Usine = Tout ; Compta + Commerciale sans accès (§ 5.2) |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tickets Lesstime générés
|
||||
|
||||
**TaskGroup Lesstime** : **#33 — M5 — Tickets de pesée** (projet `ERP / Starseed`, projectId=6) — créé le 17/06/2026, 12 tickets au statut « Prêt à dev ».
|
||||
|
||||
| # | ERP | Ticket | Effort | Tag |
|
||||
|---|---|---|---|---|
|
||||
| 1.1 | ERP-181 | Scaffolder le module Logistique + RBAC | M | Backend |
|
||||
| 1.2 | ERP-182 | Migrer le schéma M5 (site.code, compteurs, weighing_ticket) | M | Backend |
|
||||
| 1.3 | ERP-183 | Créer l'entité WeighingTicket + repository + contrat sérialisation | M | Backend |
|
||||
| 1.4 | ERP-184 | Implémenter la pesée pont bascule (stub + DSD + endpoint) | M | Backend |
|
||||
| 1.5 | ERP-185 | Créer Provider + Processor (numérotation, RG, normalisation) | L | Backend |
|
||||
| 1.6 | ERP-186 | Implémenter l'export XLSX | S | Backend |
|
||||
| 1.7 | ERP-187 | Tests PHPUnit RG-5.01→5.10 + capture contrat JSON | M | Backend |
|
||||
| 1.8 | ERP-188 | Créer la page liste `/weighing-tickets` + export | M | Frontend |
|
||||
| 1.9 | ERP-189 | Implémenter l'écran Ajouter (blocs vide+plein, pesée, masque immat) | L | Frontend |
|
||||
| 1.10 | ERP-190 | Implémenter l'écran Modification + déclenchement impression | M | Frontend |
|
||||
| 1.11 | ERP-191 | i18n + libellés + branchement site courant | S | Frontend |
|
||||
| 1.12 | ERP-192 | **Bon d'impression du ticket de pesée — OWNER Tristan** | — | Frontend |
|
||||
@@ -35,6 +35,14 @@
|
||||
"section": "Technique",
|
||||
"providers": "Répertoire prestataires"
|
||||
},
|
||||
"transport": {
|
||||
"section": "Transport",
|
||||
"carriers": "Répertoire transporteurs"
|
||||
},
|
||||
"logistique": {
|
||||
"section": "Logistique",
|
||||
"weighing_tickets": "Tickets de pesée"
|
||||
},
|
||||
"core": {
|
||||
"roles": "Gestion des rôles",
|
||||
"users": "Utilisateurs",
|
||||
@@ -549,7 +557,12 @@
|
||||
"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",
|
||||
"logistique_weighingticket": "Ticket de pesée"
|
||||
},
|
||||
"empty": "Aucune activité enregistrée",
|
||||
"no_results": "Aucun résultat pour ces filtres",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
@@ -95,6 +95,19 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
'technique.providers.archive',
|
||||
// Transport — Repertoire transporteurs (M4, ERP-153). Meme logique :
|
||||
// mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE
|
||||
// n°7). transport.carriers.view n'ajoute pas de lien dans la section
|
||||
// Administration, donc expectedAdminLinks reste inchange.
|
||||
'transport.carriers.view',
|
||||
'transport.carriers.manage',
|
||||
'transport.carriers.archive',
|
||||
// Logistique — Tickets de pesee (M5, ERP-181). Meme logique : mappe sur
|
||||
// le persona "tout", pas de nouveau persona (regle ABSOLUE n°7).
|
||||
// logistique.weighing_tickets.view n'ajoute pas de lien dans la section
|
||||
// Administration, donc expectedAdminLinks reste inchange.
|
||||
'logistique.weighing_tickets.view',
|
||||
'logistique.weighing_tickets.manage',
|
||||
],
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* M5 — Tickets de pesee (ERP-182) : creation du schema BDD du module Logistique.
|
||||
*
|
||||
* Objets crees :
|
||||
* - site.code : code court du site (86/17/82), prefixe de numerotation des
|
||||
* tickets (RG-5.02). Backfill depuis les 2 premiers chiffres du code postal
|
||||
* + index unique uq_site_code (§ 2.5). NULLABLE a ce ticket (l'entite Site ne
|
||||
* mappe pas encore `code`) ; le mapping ORM + peuplement + SET NOT NULL sont
|
||||
* portes par le ticket entite (WeighingTicket).
|
||||
* - weighing_ticket_counter : sequence du numero de ticket par site (RG-5.02).
|
||||
* - weighbridge_dsd_counter : compteur DSD du pont bascule par site (RG-5.04).
|
||||
* - weighing_ticket : table principale (contrepartie Client/Fournisseur/Autre,
|
||||
* immatriculation partagee, pesees a vide + a plein en colonnes plates,
|
||||
* poids net derive, soft-delete prepare + Timestampable/Blamable).
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
|
||||
* la table porte des FK cross-module (user, client, supplier, site). Le tri par
|
||||
* timestamp au sein du namespace racine garantit l'ordre apres la creation de
|
||||
* ces tables sur base vide ; un namespace modulaire casserait `make db-reset`.
|
||||
*
|
||||
* Convention IDs (spec § 2.2) : `INT GENERATED BY DEFAULT AS IDENTITY`,
|
||||
* horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le TimestampableBlamableTrait
|
||||
* mappe `datetime_immutable`).
|
||||
*
|
||||
* Chaque colonne porte son `COMMENT ON COLUMN` (regle ABSOLUE n°12).
|
||||
*
|
||||
* NB schema:update (test-db-setup) :
|
||||
* - weighing_ticket_counter / weighbridge_dsd_counter ne sont JAMAIS mappees en
|
||||
* ORM (DBAL brut sous verrou FOR UPDATE, § 2.5 / § 2.7) -> exclues du
|
||||
* `schema_filter` (config/packages/doctrine.yaml) pour que schema:update ne
|
||||
* les drope pas. Leurs descriptions sont aussi catalogue-es dans
|
||||
* ColumnCommentsCatalog (rejeu par `app:apply-column-comments`).
|
||||
* - weighing_ticket et la colonne site.code seront mappes en ORM au ticket
|
||||
* suivant (entite WeighingTicket + propriete Site::code) ; d'ici la,
|
||||
* schema:update les drope sur la base de TEST uniquement (sans impact : aucun
|
||||
* test ne les reference encore, et dev/prod ne lancent jamais schema:update).
|
||||
*/
|
||||
final class Version20260617150000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-182 (M5) : site.code + compteurs (numero ticket / DSD) + table weighing_ticket (tickets de pesee).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSiteCode();
|
||||
$this->createWeighingTicketCounter();
|
||||
$this->createWeighbridgeDsdCounter();
|
||||
$this->createWeighingTicket();
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Ordre inverse : table principale puis compteurs, enfin la colonne site.code.
|
||||
$this->addSql('DROP TABLE IF EXISTS weighing_ticket');
|
||||
$this->addSql('DROP TABLE IF EXISTS weighbridge_dsd_counter');
|
||||
$this->addSql('DROP TABLE IF EXISTS weighing_ticket_counter');
|
||||
$this->addSql('DROP INDEX IF EXISTS uq_site_code');
|
||||
$this->addSql('ALTER TABLE site DROP COLUMN IF EXISTS code');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// site.code — prefixe de numerotation des tickets (§ 2.5)
|
||||
// =================================================================
|
||||
|
||||
private function addSiteCode(): void
|
||||
{
|
||||
// Colonne NULLABLE a ce ticket : l'entite Site ne mappe pas encore `code`,
|
||||
// donc tout persist ORM (fixtures, tests) l'omettrait -> un NOT NULL casserait
|
||||
// `make db-reset`. Le mapping ORM Site::code, son peuplement (86/17/82) et le
|
||||
// passage `SET NOT NULL` sont portes par le ticket suivant (entite WeighingTicket
|
||||
// + Site::code), via une 2e migration. L'index unique est pose des maintenant
|
||||
// (Postgres tolere plusieurs NULL) : il garantit l'unicite des qu'ils seront peuples.
|
||||
$this->addSql('ALTER TABLE site ADD COLUMN code VARCHAR(8) DEFAULT NULL');
|
||||
// Backfill : 2 premiers chiffres du code postal (departement) par defaut,
|
||||
// editable ensuite cote admin Sites. No-op sur base fraiche (aucun site encore).
|
||||
$this->addSql('UPDATE site SET code = LEFT(postal_code, 2) WHERE code IS NULL');
|
||||
$this->addSql('CREATE UNIQUE INDEX uq_site_code ON site (code)');
|
||||
|
||||
$this->comment('site', 'code', 'Code court du site (ex. 86/17/82) — prefixe de numerotation des tickets de pesee (RG-5.02). Unique (uq_site_code). Backfill = 2 premiers chiffres du CP. NOT NULL pose au ticket entite.');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Compteur du numero de ticket (sequence par site) — RG-5.02
|
||||
// =================================================================
|
||||
|
||||
private function createWeighingTicketCounter(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE weighing_ticket_counter (
|
||||
site_id INT NOT NULL,
|
||||
last_value INT DEFAULT 0 NOT NULL,
|
||||
PRIMARY KEY (site_id),
|
||||
CONSTRAINT fk_wt_counter_site
|
||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->comment('weighing_ticket_counter', '_table', 'Sequence du numero de ticket de pesee par site (RG-5.02, M5 Logistique) — incrementee en DBAL brut sous verrou FOR UPDATE, hors ORM.');
|
||||
$this->comment('weighing_ticket_counter', 'site_id', 'Site proprietaire de la sequence (1 ligne par site). PK + FK -> site.id, ON DELETE CASCADE.');
|
||||
$this->comment('weighing_ticket_counter', 'last_value', 'Dernier numero de ticket attribue pour le site. Increment verrouille FOR UPDATE (RG-5.02).');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Compteur DSD (pesee du pont, par site) — RG-5.04
|
||||
// =================================================================
|
||||
|
||||
private function createWeighbridgeDsdCounter(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE weighbridge_dsd_counter (
|
||||
site_id INT NOT NULL,
|
||||
last_value INT DEFAULT 0 NOT NULL,
|
||||
PRIMARY KEY (site_id),
|
||||
CONSTRAINT fk_dsd_counter_site
|
||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->comment('weighbridge_dsd_counter', '_table', 'Compteur DSD du pont bascule par site (RG-5.04, M5 Logistique) — chaque pesee consomme une valeur. Incremente en DBAL brut sous verrou FOR UPDATE, hors ORM.');
|
||||
$this->comment('weighbridge_dsd_counter', 'site_id', 'Site proprietaire du compteur (1 pont par site). PK + FK -> site.id, ON DELETE CASCADE.');
|
||||
$this->comment('weighbridge_dsd_counter', 'last_value', 'Derniere valeur DSD attribuee pour le site (pont bascule). Increment verrouille FOR UPDATE (RG-5.04).');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Table principale `weighing_ticket`
|
||||
// =================================================================
|
||||
|
||||
private function createWeighingTicket(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE weighing_ticket (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
site_id INT NOT NULL,
|
||||
number VARCHAR(20) NOT NULL,
|
||||
counterparty_type VARCHAR(12) NOT NULL,
|
||||
client_id INT DEFAULT NULL,
|
||||
supplier_id INT DEFAULT NULL,
|
||||
other_label VARCHAR(255) DEFAULT NULL,
|
||||
immatriculation VARCHAR(20) NOT NULL,
|
||||
plate_free_format BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
empty_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
empty_weight INT DEFAULT NULL,
|
||||
empty_dsd INT DEFAULT NULL,
|
||||
empty_mode VARCHAR(8) DEFAULT NULL,
|
||||
empty_manual_number VARCHAR(50) DEFAULT NULL,
|
||||
full_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
full_weight INT DEFAULT NULL,
|
||||
full_dsd INT DEFAULT NULL,
|
||||
full_mode VARCHAR(8) DEFAULT NULL,
|
||||
full_manual_number VARCHAR(50) DEFAULT NULL,
|
||||
net_weight INT 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_wt_counterparty_type
|
||||
CHECK (counterparty_type IN ('CLIENT', 'FOURNISSEUR', 'AUTRE')),
|
||||
CONSTRAINT chk_wt_empty_mode
|
||||
CHECK (empty_mode IS NULL OR empty_mode IN ('AUTO', 'MANUAL')),
|
||||
CONSTRAINT chk_wt_full_mode
|
||||
CHECK (full_mode IS NULL OR full_mode IN ('AUTO', 'MANUAL')),
|
||||
CONSTRAINT chk_wt_client_branch
|
||||
CHECK (counterparty_type <> 'CLIENT' OR (client_id IS NOT NULL AND supplier_id IS NULL AND other_label IS NULL)),
|
||||
CONSTRAINT chk_wt_supplier_branch
|
||||
CHECK (counterparty_type <> 'FOURNISSEUR' OR (supplier_id IS NOT NULL AND client_id IS NULL AND other_label IS NULL)),
|
||||
CONSTRAINT chk_wt_other_branch
|
||||
CHECK (counterparty_type <> 'AUTRE' OR (other_label IS NOT NULL AND client_id IS NULL AND supplier_id IS NULL)),
|
||||
CONSTRAINT fk_wt_site
|
||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_wt_client
|
||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_wt_supplier
|
||||
FOREIGN KEY (supplier_id) REFERENCES supplier (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_wt_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_wt_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE UNIQUE INDEX uq_weighing_ticket_number ON weighing_ticket (site_id, number)');
|
||||
$this->addSql('CREATE INDEX idx_wt_site ON weighing_ticket (site_id)');
|
||||
$this->addSql('CREATE INDEX idx_wt_client ON weighing_ticket (client_id)');
|
||||
$this->addSql('CREATE INDEX idx_wt_supplier ON weighing_ticket (supplier_id)');
|
||||
$this->addSql('CREATE INDEX idx_wt_deleted_at ON weighing_ticket (deleted_at)');
|
||||
$this->addSql('CREATE INDEX idx_wt_created_by ON weighing_ticket (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_wt_updated_by ON weighing_ticket (updated_by)');
|
||||
|
||||
$this->comment('weighing_ticket', '_table', 'Tickets de pesee (M5 Logistique) — pesee a vide + a plein au pont bascule, contrepartie Client/Fournisseur/Autre. Cloisonne par site courant.');
|
||||
$this->comment('weighing_ticket', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('weighing_ticket', 'site_id', 'Site du pont bascule (cloisonnement § 2.3). FK -> site.id, ON DELETE RESTRICT. Renseigne serveur depuis le site courant, immuable (RG-5.09).');
|
||||
$this->comment('weighing_ticket', 'number', 'Numero {siteCode}-TP-{NNNN}, unique par site (uq_weighing_ticket_number), immuable. Sequence weighing_ticket_counter (RG-5.02).');
|
||||
$this->comment('weighing_ticket', 'counterparty_type', 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (chk_wt_counterparty_type, RG-5.03). Pilote l obligation client_id / supplier_id / other_label.');
|
||||
$this->comment('weighing_ticket', 'client_id', 'Branche CLIENT (RG-5.03) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi counterparty_type = CLIENT, nul sinon (chk_wt_client_branch).');
|
||||
$this->comment('weighing_ticket', 'supplier_id', 'Branche FOURNISSEUR (RG-5.03) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi counterparty_type = FOURNISSEUR (chk_wt_supplier_branch).');
|
||||
$this->comment('weighing_ticket', 'other_label', 'Branche AUTRE (RG-5.03) : libelle libre de la contrepartie. Requis ssi counterparty_type = AUTRE, nul sinon (chk_wt_other_branch).');
|
||||
$this->comment('weighing_ticket', 'immatriculation', 'Plaque du vehicule, partagee entre pesee vide et plein. Masque XX-000-XX sauf si plate_free_format (RG-5.01). Normalisee serveur (trim/UPPER).');
|
||||
$this->comment('weighing_ticket', 'plate_free_format', '« Tout format » : desactive le masque XX-000-XX de l immatriculation (RG-5.01). Partage entre les 2 formulaires. Faux par defaut.');
|
||||
$this->comment('weighing_ticket', 'empty_date', 'Date/heure de la pesee a vide (tare). Defaut jour courant cote front (RG-5.07). Null tant que la pesee vide n est pas faite.');
|
||||
$this->comment('weighing_ticket', 'empty_weight', 'Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).');
|
||||
$this->comment('weighing_ticket', 'empty_dsd', 'Compteur DSD du pont a la pesee a vide. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).');
|
||||
$this->comment('weighing_ticket', 'empty_mode', 'Mode de la pesee a vide : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_empty_mode (RG-5.06).');
|
||||
$this->comment('weighing_ticket', 'empty_manual_number', 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a vide (RG-5.04).');
|
||||
$this->comment('weighing_ticket', 'full_date', 'Date/heure de la pesee a plein (brut). Null tant que la pesee plein n est pas faite.');
|
||||
$this->comment('weighing_ticket', 'full_weight', 'Poids a plein (brut) en kg — readonly UI, rempli par la pesee (RG-5.07).');
|
||||
$this->comment('weighing_ticket', 'full_dsd', 'Compteur DSD du pont a la pesee a plein. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).');
|
||||
$this->comment('weighing_ticket', 'full_mode', 'Mode de la pesee a plein : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_full_mode (RG-5.06).');
|
||||
$this->comment('weighing_ticket', 'full_manual_number', 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a plein (RG-5.04).');
|
||||
$this->comment('weighing_ticket', 'net_weight', 'Poids net = full_weight - empty_weight (kg), calcule serveur (RG-5.05). Null si une pesee manque. Colonne Poids de la liste.');
|
||||
$this->comment('weighing_ticket', 'deleted_at', 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.');
|
||||
$this->addTimestampableBlamableComments('weighing_ticket');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Helpers (identiques au M4 Version20260615150000)
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* 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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* M5 — Tickets de pesee (ERP-183) : finalisation de site.code en NOT NULL.
|
||||
*
|
||||
* Cadencement en 2 temps (RETEX dev ERP-182, § 2.5) :
|
||||
* - ERP-182 (Version20260617150000) a cree site.code NULLABLE + backfill +
|
||||
* index unique uq_site_code, car l'entite Site ne mappait pas encore `code`
|
||||
* (un persist ORM l'aurait omis -> violation NOT NULL au `make db-reset`).
|
||||
* - ERP-183 mappe desormais Site::code (propriete + getter/setter + derivation
|
||||
* auto du CP au prePersist) et le peuple dans SitesFixtures (86/17/82). La
|
||||
* colonne est donc systematiquement renseignee : on peut poser le NOT NULL.
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11), comme la migration
|
||||
* de schema M5 dont celle-ci depend (elle doit s'executer apres).
|
||||
*
|
||||
* Le COMMENT ON COLUMN est repose avec le texte definitif (sans la mention
|
||||
* « NOT NULL pose au ticket entite » devenue caduque), aligne sur l'entree
|
||||
* `site.code` du ColumnCommentsCatalog (chemin schema:update de la BDD de test).
|
||||
*/
|
||||
final class Version20260617160000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-183 (M5) : site.code -> NOT NULL (la propriete ORM Site::code est desormais mappee et peuplee).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE site ALTER COLUMN code SET NOT NULL');
|
||||
$this->addSql("COMMENT ON COLUMN site.code IS \$_\$Code court du site (ex. 86/17/82) — prefixe de numerotation des tickets de pesee (RG-5.02). Auto-derive des 2 premiers chiffres du CP a la creation, editable ensuite. Unique (uq_site_code).\$_\$");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE site ALTER COLUMN code DROP NOT NULL');
|
||||
}
|
||||
}
|
||||
@@ -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,12 @@ 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',
|
||||
// Tickets de pesee (M5 § 5.2, ERP-181) : view + manage (« Tout »).
|
||||
'logistique.weighing_tickets.view',
|
||||
'logistique.weighing_tickets.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 +126,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).
|
||||
@@ -131,9 +140,14 @@ final class RbacSeeder
|
||||
'label' => 'Usine',
|
||||
// Prestataires (M3 § 2.9 + § 2.13, ERP-138) : view en lecture seule,
|
||||
// SANS `sites.bypass_scope` -> cloisonne aux prestataires de son site
|
||||
// courant. Aucun autre acces metier.
|
||||
// courant.
|
||||
'permissions' => [
|
||||
'technique.providers.view',
|
||||
// Tickets de pesee (M5 § 5.2, ERP-181) : view + manage. L'Usine
|
||||
// pese sur site -> reste cloisonnee a son site courant (pas de
|
||||
// bypass_scope ; les tickets sont filtres par SiteScopedQueryExtension).
|
||||
'logistique.weighing_tickets.view',
|
||||
'logistique.weighing_tickets.manage',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -212,6 +212,15 @@ 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',
|
||||
// Logistique — Tickets de pesee (M5, ERP-181). Meme logique :
|
||||
// mappe sur le persona "tout". Miroir de personas.ts.
|
||||
'logistique.weighing_tickets.view',
|
||||
'logistique.weighing_tickets.manage',
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Commercial\Domain\Entity\Client; // relation ORM partagee (§ 2.1)
|
||||
use App\Module\Commercial\Domain\Entity\Supplier; // relation ORM partagee (§ 2.1)
|
||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor;
|
||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketProvider;
|
||||
use App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository;
|
||||
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagee (§ 2.1)
|
||||
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 DateTimeImmutable;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Ticket de pesee (M5 Logistique) — entite racine du module, jumelle de
|
||||
* Carrier (M4) / Supplier (M2) cote pattern (#[Auditable], TimestampableBlamable,
|
||||
* contrat de serialisation 3 maillons). Porte EXACTEMENT deux pesees modelisees
|
||||
* en colonnes plates (vide + plein, § 2.4), une contrepartie Client/Fournisseur/
|
||||
* Autre (RG-5.03) et l'immatriculation partagee entre les deux formulaires
|
||||
* (RG-5.01).
|
||||
*
|
||||
* Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) :
|
||||
* - LISTE (weighing_ticket:read + client:read + supplier:read + site:read +
|
||||
* default:read) : number, counterpartyType, client/supplier embarques,
|
||||
* otherLabel, displayDate (= fullDate ?? emptyDate), netWeight,
|
||||
* plateFreeFormat, createdAt/updatedAt (via default:read).
|
||||
* - DETAIL (+ weighing_ticket:item:read) : ajoute site embarque, immatriculation
|
||||
* et les deux pesees (empty* / full*).
|
||||
*
|
||||
* Champs renseignes SERVEUR (lecture seule cote API, sans groupe d'ecriture) :
|
||||
* - number : numero {siteCode}-TP-{NNNN} attribue par le WeighingTicketProcessor
|
||||
* (RG-5.02, immuable) ;
|
||||
* - site : resolu depuis le site courant a la creation (CurrentSiteProvider,
|
||||
* § 2.3), immuable (RG-5.09) ;
|
||||
* - netWeight : poids net derive plein - vide, recalcule serveur (RG-5.05).
|
||||
*
|
||||
* Les RG inter-champs (RG-5.03 : champ associe a counterpartyType obligatoire)
|
||||
* passent par une contrainte d'entite (Assert\Callback + ->atPath()) pour que
|
||||
* chaque 422 porte un propertyPath exploitable par useFormErrors (mapping inline,
|
||||
* pas un toast — ERP-101). L'exclusivite « les autres champs forces nuls » est
|
||||
* garantie par les CHECK Postgres (chk_wt_*_branch) + la normalisation du
|
||||
* Processor (ERP-185). Pas de Delete, pas d'archive au M5 (§ 2.13).
|
||||
*
|
||||
* @see WeighingTicketProvider Lecture (liste paginee filtree site courant + item) — ERP-185.
|
||||
* @see WeighingTicketProcessor Ecriture (numerotation, normalisation, net) — ERP-185.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('logistique.weighing_tickets.view')",
|
||||
normalizationContext: ['groups' => [
|
||||
'weighing_ticket:read',
|
||||
'client:read',
|
||||
'supplier:read',
|
||||
'site:read',
|
||||
'default:read',
|
||||
]],
|
||||
provider: WeighingTicketProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('logistique.weighing_tickets.view')",
|
||||
normalizationContext: ['groups' => [
|
||||
'weighing_ticket:read',
|
||||
'weighing_ticket:item:read',
|
||||
'client:read',
|
||||
'supplier:read',
|
||||
'site:read',
|
||||
'default:read',
|
||||
]],
|
||||
provider: WeighingTicketProvider::class,
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('logistique.weighing_tickets.manage')",
|
||||
normalizationContext: ['groups' => [
|
||||
'weighing_ticket:read',
|
||||
'weighing_ticket:item:read',
|
||||
'client:read',
|
||||
'supplier:read',
|
||||
'site:read',
|
||||
'default:read',
|
||||
]],
|
||||
denormalizationContext: ['groups' => ['weighing_ticket:write']],
|
||||
processor: WeighingTicketProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('logistique.weighing_tickets.manage')",
|
||||
normalizationContext: ['groups' => [
|
||||
'weighing_ticket:read',
|
||||
'weighing_ticket:item:read',
|
||||
'client:read',
|
||||
'supplier:read',
|
||||
'site:read',
|
||||
'default:read',
|
||||
]],
|
||||
denormalizationContext: ['groups' => ['weighing_ticket:write']],
|
||||
provider: WeighingTicketProvider::class,
|
||||
processor: WeighingTicketProcessor::class,
|
||||
),
|
||||
// Pas de Delete au M5 (HP-M5-05). Pas d'archive (hors docx).
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineWeighingTicketRepository::class)]
|
||||
#[ORM\Table(name: 'weighing_ticket')]
|
||||
#[ORM\Index(name: 'idx_wt_site', columns: ['site_id'])]
|
||||
#[ORM\Index(name: 'idx_wt_client', columns: ['client_id'])]
|
||||
#[ORM\Index(name: 'idx_wt_supplier', columns: ['supplier_id'])]
|
||||
#[ORM\Index(name: 'idx_wt_deleted_at', columns: ['deleted_at'])]
|
||||
#[ORM\Index(name: 'idx_wt_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_wt_updated_by', columns: ['updated_by'])]
|
||||
#[ORM\UniqueConstraint(name: 'uq_weighing_ticket_number', columns: ['site_id', 'number'])]
|
||||
#[Auditable]
|
||||
class WeighingTicket implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['weighing_ticket:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
/** Numero {siteCode}-TP-{NNNN} — attribue serveur, lecture seule, immuable (RG-5.02). */
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Groups(['weighing_ticket:read'])]
|
||||
private ?string $number = null;
|
||||
|
||||
/** Site du pont bascule — resolu serveur depuis le site courant, immuable (§ 2.3 / RG-5.09). */
|
||||
#[ORM\ManyToOne(targetEntity: Site::class)]
|
||||
#[ORM\JoinColumn(name: 'site_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
#[Groups(['weighing_ticket:item:read'])]
|
||||
private ?Site $site = null;
|
||||
|
||||
/** CLIENT | FOURNISSEUR | AUTRE (RG-5.03) — pilote le champ associe obligatoire. */
|
||||
#[ORM\Column(name: 'counterparty_type', length: 12)]
|
||||
#[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.')]
|
||||
#[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', 'AUTRE'], message: 'Type de contrepartie invalide.')]
|
||||
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||
private ?string $counterpartyType = null;
|
||||
|
||||
/** Requis ssi counterpartyType = CLIENT (validateCounterpartyConsistency, RG-5.03). */
|
||||
#[ORM\ManyToOne(targetEntity: Client::class)]
|
||||
#[ORM\JoinColumn(name: 'client_id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||
private ?Client $client = null;
|
||||
|
||||
/** Requis ssi counterpartyType = FOURNISSEUR (RG-5.03). */
|
||||
#[ORM\ManyToOne(targetEntity: Supplier::class)]
|
||||
#[ORM\JoinColumn(name: 'supplier_id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||
private ?Supplier $supplier = null;
|
||||
|
||||
/** Libelle libre — requis ssi counterpartyType = AUTRE (RG-5.03). */
|
||||
#[ORM\Column(name: 'other_label', length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le libellé ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||
private ?string $otherLabel = null;
|
||||
|
||||
/** Plaque du vehicule, partagee entre les 2 formulaires (RG-5.01). Masque XX-000-XX sauf plateFreeFormat. */
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\NotBlank(message: 'L\'immatriculation est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 20, maxMessage: 'L\'immatriculation ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||
private ?string $immatriculation = null;
|
||||
|
||||
// « Tout format » : desactive le masque XX-000-XX (RG-5.01). Le groupe de
|
||||
// LECTURE est porte par le getter isPlateFreeFormat() (+ SerializedName,
|
||||
// piege booleen #3 M1) ; le groupe d'ECRITURE vit ici pour cibler le setter.
|
||||
#[ORM\Column(name: 'plate_free_format', options: ['default' => false])]
|
||||
#[Groups(['weighing_ticket:write'])]
|
||||
private bool $plateFreeFormat = false;
|
||||
|
||||
// === Pesee a vide (§ 2.4) ===
|
||||
|
||||
#[ORM\Column(name: 'empty_date', type: 'datetime_immutable', nullable: true)]
|
||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||
private ?DateTimeImmutable $emptyDate = null;
|
||||
|
||||
/** Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07). */
|
||||
#[ORM\Column(name: 'empty_weight', nullable: true)]
|
||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||
private ?int $emptyWeight = null;
|
||||
|
||||
#[ORM\Column(name: 'empty_dsd', nullable: true)]
|
||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||
private ?int $emptyDsd = null;
|
||||
|
||||
/** AUTO (pont bascule) | MANUAL (saisie) — chk_wt_empty_mode (RG-5.06). */
|
||||
#[ORM\Column(name: 'empty_mode', length: 8, nullable: true)]
|
||||
#[Assert\Choice(choices: ['AUTO', 'MANUAL'], message: 'Mode de pesée invalide.')]
|
||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||
private ?string $emptyMode = null;
|
||||
|
||||
/** Numero de pesee saisi en manuelle (distinct du DSD) — RG-5.04. */
|
||||
#[ORM\Column(name: 'empty_manual_number', length: 50, nullable: true)]
|
||||
#[Assert\Length(max: 50, maxMessage: 'Le numéro de pesée ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||
private ?string $emptyManualNumber = null;
|
||||
|
||||
// === Pesee a plein (§ 2.4) ===
|
||||
|
||||
#[ORM\Column(name: 'full_date', type: 'datetime_immutable', nullable: true)]
|
||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||
private ?DateTimeImmutable $fullDate = null;
|
||||
|
||||
/** Poids a plein (brut) en kg — readonly UI, rempli par la pesee (RG-5.07). */
|
||||
#[ORM\Column(name: 'full_weight', nullable: true)]
|
||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||
private ?int $fullWeight = null;
|
||||
|
||||
#[ORM\Column(name: 'full_dsd', nullable: true)]
|
||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||
private ?int $fullDsd = null;
|
||||
|
||||
/** AUTO (pont bascule) | MANUAL (saisie) — chk_wt_full_mode (RG-5.06). */
|
||||
#[ORM\Column(name: 'full_mode', length: 8, nullable: true)]
|
||||
#[Assert\Choice(choices: ['AUTO', 'MANUAL'], message: 'Mode de pesée invalide.')]
|
||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||
private ?string $fullMode = null;
|
||||
|
||||
/** Numero de pesee saisi en manuelle (distinct du DSD) — RG-5.04. */
|
||||
#[ORM\Column(name: 'full_manual_number', length: 50, nullable: true)]
|
||||
#[Assert\Length(max: 50, maxMessage: 'Le numéro de pesée ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])]
|
||||
private ?string $fullManualNumber = null;
|
||||
|
||||
/** Poids net derive plein - vide (kg) — calcule serveur (RG-5.05). Colonne Poids de la liste. */
|
||||
#[ORM\Column(name: 'net_weight', nullable: true)]
|
||||
#[Groups(['weighing_ticket:read'])]
|
||||
private ?int $netWeight = null;
|
||||
|
||||
/** Soft-delete technique prepare mais non expose au M5 (§ 2.13) — pas de groupe. */
|
||||
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
|
||||
private ?DateTimeImmutable $deletedAt = null;
|
||||
|
||||
/**
|
||||
* Coherence de la contrepartie (RG-5.03). Decision figee (miroir M4
|
||||
* validateMainFormConsistency) : porte par une contrainte d'entite
|
||||
* (Assert\Callback + ->atPath()) pour que chaque 422 soit mappee inline sous
|
||||
* le champ par useFormErrors (pas un toast — ERP-101). Jouee par API Platform
|
||||
* AVANT le Processor, sur POST comme sur PATCH.
|
||||
*
|
||||
* Ne valide ICI que la PRESENCE du champ associe au type. L'exclusivite
|
||||
* « les autres champs forces nuls » est garantie par les CHECK Postgres
|
||||
* (chk_wt_*_branch) et la normalisation du Processor (qui null-ifie les
|
||||
* champs hors-branche — ERP-185).
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateCounterpartyConsistency(ExecutionContextInterface $context): void
|
||||
{
|
||||
switch ($this->counterpartyType) {
|
||||
case 'CLIENT':
|
||||
if (null === $this->client) {
|
||||
$context->buildViolation('Le client est obligatoire pour une contrepartie « Client ».')
|
||||
->atPath('client')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'FOURNISSEUR':
|
||||
if (null === $this->supplier) {
|
||||
$context->buildViolation('Le fournisseur est obligatoire pour une contrepartie « Fournisseur ».')
|
||||
->atPath('supplier')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'AUTRE':
|
||||
if (null === $this->otherLabel || '' === trim($this->otherLabel)) {
|
||||
$context->buildViolation('Le libellé est obligatoire pour une contrepartie « Autre ».')
|
||||
->atPath('otherLabel')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Date du ticket affichee en LISTE (§ 4.0) : date de la pesee a plein si
|
||||
* disponible, sinon date de la pesee a vide. Getter calcule (jamais
|
||||
* persiste), expose en lecture seule.
|
||||
*/
|
||||
#[Groups(['weighing_ticket:read'])]
|
||||
public function getDisplayDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->fullDate ?? $this->emptyDate;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getNumber(): ?string
|
||||
{
|
||||
return $this->number;
|
||||
}
|
||||
|
||||
public function setNumber(?string $number): static
|
||||
{
|
||||
$this->number = $number;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSite(): ?Site
|
||||
{
|
||||
return $this->site;
|
||||
}
|
||||
|
||||
public function setSite(?Site $site): static
|
||||
{
|
||||
$this->site = $site;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCounterpartyType(): ?string
|
||||
{
|
||||
return $this->counterpartyType;
|
||||
}
|
||||
|
||||
public function setCounterpartyType(?string $counterpartyType): static
|
||||
{
|
||||
$this->counterpartyType = $counterpartyType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getClient(): ?Client
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function setClient(?Client $client): static
|
||||
{
|
||||
$this->client = $client;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSupplier(): ?Supplier
|
||||
{
|
||||
return $this->supplier;
|
||||
}
|
||||
|
||||
public function setSupplier(?Supplier $supplier): static
|
||||
{
|
||||
$this->supplier = $supplier;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getOtherLabel(): ?string
|
||||
{
|
||||
return $this->otherLabel;
|
||||
}
|
||||
|
||||
public function setOtherLabel(?string $otherLabel): static
|
||||
{
|
||||
$this->otherLabel = $otherLabel;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getImmatriculation(): ?string
|
||||
{
|
||||
return $this->immatriculation;
|
||||
}
|
||||
|
||||
public function setImmatriculation(?string $immatriculation): static
|
||||
{
|
||||
$this->immatriculation = $immatriculation;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// Piege booleen (RETEX M1 #3) : #[Groups] + #[SerializedName] sur le getter,
|
||||
// sinon Symfony strip le prefixe « is » et drope la cle plateFreeFormat du JSON.
|
||||
#[Groups(['weighing_ticket:read'])]
|
||||
#[SerializedName('plateFreeFormat')]
|
||||
public function isPlateFreeFormat(): bool
|
||||
{
|
||||
return $this->plateFreeFormat;
|
||||
}
|
||||
|
||||
public function setPlateFreeFormat(bool $plateFreeFormat): static
|
||||
{
|
||||
$this->plateFreeFormat = $plateFreeFormat;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmptyDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->emptyDate;
|
||||
}
|
||||
|
||||
public function setEmptyDate(?DateTimeImmutable $emptyDate): static
|
||||
{
|
||||
$this->emptyDate = $emptyDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmptyWeight(): ?int
|
||||
{
|
||||
return $this->emptyWeight;
|
||||
}
|
||||
|
||||
public function setEmptyWeight(?int $emptyWeight): static
|
||||
{
|
||||
$this->emptyWeight = $emptyWeight;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmptyDsd(): ?int
|
||||
{
|
||||
return $this->emptyDsd;
|
||||
}
|
||||
|
||||
public function setEmptyDsd(?int $emptyDsd): static
|
||||
{
|
||||
$this->emptyDsd = $emptyDsd;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmptyMode(): ?string
|
||||
{
|
||||
return $this->emptyMode;
|
||||
}
|
||||
|
||||
public function setEmptyMode(?string $emptyMode): static
|
||||
{
|
||||
$this->emptyMode = $emptyMode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmptyManualNumber(): ?string
|
||||
{
|
||||
return $this->emptyManualNumber;
|
||||
}
|
||||
|
||||
public function setEmptyManualNumber(?string $emptyManualNumber): static
|
||||
{
|
||||
$this->emptyManualNumber = $emptyManualNumber;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFullDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->fullDate;
|
||||
}
|
||||
|
||||
public function setFullDate(?DateTimeImmutable $fullDate): static
|
||||
{
|
||||
$this->fullDate = $fullDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFullWeight(): ?int
|
||||
{
|
||||
return $this->fullWeight;
|
||||
}
|
||||
|
||||
public function setFullWeight(?int $fullWeight): static
|
||||
{
|
||||
$this->fullWeight = $fullWeight;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFullDsd(): ?int
|
||||
{
|
||||
return $this->fullDsd;
|
||||
}
|
||||
|
||||
public function setFullDsd(?int $fullDsd): static
|
||||
{
|
||||
$this->fullDsd = $fullDsd;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFullMode(): ?string
|
||||
{
|
||||
return $this->fullMode;
|
||||
}
|
||||
|
||||
public function setFullMode(?string $fullMode): static
|
||||
{
|
||||
$this->fullMode = $fullMode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFullManualNumber(): ?string
|
||||
{
|
||||
return $this->fullManualNumber;
|
||||
}
|
||||
|
||||
public function setFullManualNumber(?string $fullManualNumber): static
|
||||
{
|
||||
$this->fullManualNumber = $fullManualNumber;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNetWeight(): ?int
|
||||
{
|
||||
return $this->netWeight;
|
||||
}
|
||||
|
||||
public function setNetWeight(?int $netWeight): static
|
||||
{
|
||||
$this->netWeight = $netWeight;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->deletedAt;
|
||||
}
|
||||
|
||||
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
|
||||
{
|
||||
$this->deletedAt = $deletedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Domain\Repository;
|
||||
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
* Contrat du repository des tickets de pesee (M5). Implementation Doctrine :
|
||||
* App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository.
|
||||
*/
|
||||
interface WeighingTicketRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?WeighingTicket;
|
||||
|
||||
public function save(WeighingTicket $ticket): void;
|
||||
|
||||
/**
|
||||
* QueryBuilder de SELECTION (recherche + tri) pour la liste, exploite par le
|
||||
* WeighingTicketProvider (ERP-185) qui le wrappe dans un Paginator (règle
|
||||
* ABSOLUE n°13). Exclut les soft-deletes (deleted_at IS NOT NULL). Tri par
|
||||
* defaut number DESC (plus recents en tete, § 4.1).
|
||||
*
|
||||
* Le cloisonnement par site courant n'est PAS applique ici : il l'est
|
||||
* automatiquement par le SiteScopedQueryExtension (Sites, § 2.3).
|
||||
*
|
||||
* @param null|string $search recherche fuzzy sur number, nom client/fournisseur,
|
||||
* other_label et immatriculation (§ 4.1)
|
||||
*/
|
||||
public function createListQueryBuilder(?string $search = null): QueryBuilder;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use App\Module\Logistique\Domain\Repository\WeighingTicketRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<WeighingTicket>
|
||||
*/
|
||||
class DoctrineWeighingTicketRepository extends ServiceEntityRepository implements WeighingTicketRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, WeighingTicket::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?WeighingTicket
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function save(WeighingTicket $ticket): void
|
||||
{
|
||||
$this->getEntityManager()->persist($ticket);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function createListQueryBuilder(?string $search = null): QueryBuilder
|
||||
{
|
||||
// Left-join des contreparties pour la recherche par nom (sans cartesien
|
||||
// dangereux : ManyToOne). Le cloisonnement par site courant est ajoute
|
||||
// par le SiteScopedQueryExtension (§ 2.3). Tri par defaut number DESC.
|
||||
$qb = $this->createQueryBuilder('wt')
|
||||
->leftJoin('wt.client', 'c')
|
||||
->leftJoin('wt.supplier', 's')
|
||||
->andWhere('wt.deletedAt IS NULL')
|
||||
->orderBy('wt.number', 'DESC')
|
||||
;
|
||||
|
||||
$this->applySearch($qb, $search);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche fuzzy insensible a la casse sur le numero, le nom du client /
|
||||
* fournisseur, le libelle « Autre » et l'immatriculation (§ 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(wt.number) LIKE :search '
|
||||
.'OR LOWER(c.companyName) LIKE :search '
|
||||
.'OR LOWER(s.companyName) LIKE :search '
|
||||
.'OR LOWER(wt.otherLabel) LIKE :search '
|
||||
.'OR LOWER(wt.immatriculation) LIKE :search',
|
||||
)->setParameter('search', $pattern);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique;
|
||||
|
||||
final class LogistiqueModule
|
||||
{
|
||||
public const string ID = 'logistique';
|
||||
public const string LABEL = 'Logistique';
|
||||
public const bool REQUIRED = false;
|
||||
|
||||
/**
|
||||
* Liste declarative des permissions RBAC exposees par le module Logistique.
|
||||
*
|
||||
* Socle des tickets de pesee (M5 § 5.1, ERP-181) :
|
||||
* - `view` : consultation de la liste / fiche d'un ticket de pesee ;
|
||||
* - `manage` : creation / modification d'un ticket de pesee.
|
||||
*
|
||||
* Consommee par `app:sync-permissions`. Matrice role -> permissions dans
|
||||
* `RbacSeeder::MATRIX` (§ 5.2 : Bureau / Usine = view + manage ; Compta /
|
||||
* Commerciale = aucun acces).
|
||||
*
|
||||
* @return array<int, array{code: string, label: string}>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'logistique.weighing_tickets.view', 'label' => 'Voir les tickets de pesée'],
|
||||
['code' => 'logistique.weighing_tickets.manage', 'label' => 'Créer / modifier les tickets de pesée'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Table(name: 'site')]
|
||||
#[Auditable]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
|
||||
#[ORM\UniqueConstraint(name: 'uq_site_code', columns: ['code'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[UniqueEntity(fields: ['name'], message: 'Un site avec ce nom existe deja.')]
|
||||
class Site implements SiteInterface
|
||||
@@ -88,6 +89,16 @@ class Site implements SiteInterface
|
||||
#[Groups(['site:read', 'site:write', 'me:read'])]
|
||||
private string $name;
|
||||
|
||||
// Code court du site (86/17/82) — prefixe de numerotation des tickets de
|
||||
// pesee (RG-5.02, M5 Logistique). Auto-derive des 2 premiers chiffres du
|
||||
// code postal (departement) a la creation s'il n'est pas fourni
|
||||
// explicitement (onPrePersist, § 2.5), editable ensuite cote admin Sites.
|
||||
// Unique en base (uq_site_code).
|
||||
#[ORM\Column(length: 8)]
|
||||
#[Assert\Length(max: 8, maxMessage: 'Le code du site ne peut pas depasser {{ limit }} caracteres.')]
|
||||
#[Groups(['site:read', 'site:write', 'me:read'])]
|
||||
private ?string $code = null;
|
||||
|
||||
// Premiere ligne d'adresse : numero + voie. Requise.
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'La rue est requise.')]
|
||||
@@ -188,6 +199,20 @@ class Site implements SiteInterface
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
/**
|
||||
* A la creation, derive le code court du site des 2 premiers chiffres du
|
||||
* code postal (departement) si aucun code n'a ete fourni explicitement
|
||||
* (RG-5.02, § 2.5). Le code reste editable ensuite ; son unicite est
|
||||
* garantie en base par uq_site_code.
|
||||
*/
|
||||
#[ORM\PrePersist]
|
||||
public function onPrePersist(): void
|
||||
{
|
||||
if (null === $this->code || '' === trim($this->code)) {
|
||||
$this->code = substr($this->postalCode, 0, 2);
|
||||
}
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -205,6 +230,18 @@ class Site implements SiteInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCode(): ?string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function setCode(?string $code): static
|
||||
{
|
||||
$this->code = $code;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStreet(): string
|
||||
{
|
||||
return $this->street;
|
||||
|
||||
@@ -36,7 +36,7 @@ class SitesFixtures extends Fixture
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// Chatellerault : bleu Starseed.
|
||||
// Chatellerault : bleu Starseed. Code 86 (prefixe TP — RG-5.02).
|
||||
$this->ensureSite(
|
||||
$manager,
|
||||
name: 'Chatellerault',
|
||||
@@ -45,11 +45,12 @@ class SitesFixtures extends Fixture
|
||||
postalCode: '86100',
|
||||
city: 'Châtellerault',
|
||||
color: '#056CF2',
|
||||
code: '86',
|
||||
);
|
||||
|
||||
// Saint-Jean : jaune vif. Le nom du site (identifier) ne reflete
|
||||
// pas la ville reelle (Fontenet) — c'est une nomenclature interne
|
||||
// client.
|
||||
// client. Code 17 (prefixe TP — RG-5.02).
|
||||
$this->ensureSite(
|
||||
$manager,
|
||||
name: 'Saint-Jean',
|
||||
@@ -58,9 +59,10 @@ class SitesFixtures extends Fixture
|
||||
postalCode: '17400',
|
||||
city: 'Fontenet',
|
||||
color: '#F3CB00',
|
||||
code: '17',
|
||||
);
|
||||
|
||||
// Pommevic : vert clair.
|
||||
// Pommevic : vert clair. Code 82 (prefixe TP — RG-5.02).
|
||||
$this->ensureSite(
|
||||
$manager,
|
||||
name: 'Pommevic',
|
||||
@@ -69,6 +71,7 @@ class SitesFixtures extends Fixture
|
||||
postalCode: '82400',
|
||||
city: 'Pommevic',
|
||||
color: '#74BF04',
|
||||
code: '82',
|
||||
);
|
||||
|
||||
$manager->flush();
|
||||
@@ -91,11 +94,13 @@ class SitesFixtures extends Fixture
|
||||
string $postalCode,
|
||||
string $city,
|
||||
string $color,
|
||||
string $code,
|
||||
): Site {
|
||||
$site = $this->siteRepository->findByName($name);
|
||||
|
||||
if (null === $site) {
|
||||
$site = new Site($name, $street, $complement, $postalCode, $city, $color);
|
||||
$site->setCode($code);
|
||||
$manager->persist($site);
|
||||
|
||||
return $site;
|
||||
@@ -106,6 +111,7 @@ class SitesFixtures extends Fixture
|
||||
$site->setPostalCode($postalCode);
|
||||
$site->setCity($city);
|
||||
$site->setColor($color);
|
||||
$site->setCode($code);
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -110,6 +110,7 @@ final class ColumnCommentsCatalog
|
||||
'_table' => 'Sites geographiques — perimetre de scoping multi-site, attribues aux utilisateurs via user_site.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'name' => 'Nom du site (≤ 100 caracteres).',
|
||||
'code' => 'Code court du site (ex. 86/17/82) — prefixe de numerotation des tickets de pesee (RG-5.02). Auto-derive des 2 premiers chiffres du CP a la creation, editable ensuite. Unique (uq_site_code).',
|
||||
'city' => 'Ville du site (≤ 100 caracteres).',
|
||||
'postal_code' => 'Code postal (chaine ≤ 20 caracteres) — VARCHAR pour gerer les zeros initiaux et les formats internationaux.',
|
||||
'color' => "Code couleur hexadecimal (#RRGGBB) — differenciation visuelle dans l'UI.",
|
||||
@@ -458,6 +459,130 @@ 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(),
|
||||
|
||||
// M5 Logistique (ERP-182) — compteurs par site, hors ORM (DBAL brut
|
||||
// FOR UPDATE) donc exclus du schema_filter ; catalogues ici pour que
|
||||
// `app:apply-column-comments` rejoue leurs descriptions au besoin.
|
||||
'weighing_ticket_counter' => [
|
||||
'_table' => 'Sequence du numero de ticket de pesee par site (RG-5.02, M5 Logistique) — incrementee en DBAL brut sous verrou FOR UPDATE, hors ORM.',
|
||||
'site_id' => 'Site proprietaire de la sequence (1 ligne par site). PK + FK -> site.id, ON DELETE CASCADE.',
|
||||
'last_value' => 'Dernier numero de ticket attribue pour le site. Increment verrouille FOR UPDATE (RG-5.02).',
|
||||
],
|
||||
|
||||
'weighbridge_dsd_counter' => [
|
||||
'_table' => 'Compteur DSD du pont bascule par site (RG-5.04, M5 Logistique) — chaque pesee consomme une valeur. Incremente en DBAL brut sous verrou FOR UPDATE, hors ORM.',
|
||||
'site_id' => 'Site proprietaire du compteur (1 pont par site). PK + FK -> site.id, ON DELETE CASCADE.',
|
||||
'last_value' => 'Derniere valeur DSD attribuee pour le site (pont bascule). Increment verrouille FOR UPDATE (RG-5.04).',
|
||||
],
|
||||
|
||||
// M5 Logistique (ERP-183) — table principale, desormais mappee par
|
||||
// l'entite WeighingTicket : schema:update (test) la recree sans COMMENT
|
||||
// -> app:apply-column-comments les rejoue depuis ce catalogue. Strings
|
||||
// identiques aux COMMENT de la migration Version20260617150000.
|
||||
'weighing_ticket' => [
|
||||
'_table' => 'Tickets de pesee (M5 Logistique) — pesee a vide + a plein au pont bascule, contrepartie Client/Fournisseur/Autre. Cloisonne par site courant.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'site_id' => 'Site du pont bascule (cloisonnement § 2.3). FK -> site.id, ON DELETE RESTRICT. Renseigne serveur depuis le site courant, immuable (RG-5.09).',
|
||||
'number' => 'Numero {siteCode}-TP-{NNNN}, unique par site (uq_weighing_ticket_number), immuable. Sequence weighing_ticket_counter (RG-5.02).',
|
||||
'counterparty_type' => 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (chk_wt_counterparty_type, RG-5.03). Pilote l obligation client_id / supplier_id / other_label.',
|
||||
'client_id' => 'Branche CLIENT (RG-5.03) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi counterparty_type = CLIENT, nul sinon (chk_wt_client_branch).',
|
||||
'supplier_id' => 'Branche FOURNISSEUR (RG-5.03) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi counterparty_type = FOURNISSEUR (chk_wt_supplier_branch).',
|
||||
'other_label' => 'Branche AUTRE (RG-5.03) : libelle libre de la contrepartie. Requis ssi counterparty_type = AUTRE, nul sinon (chk_wt_other_branch).',
|
||||
'immatriculation' => 'Plaque du vehicule, partagee entre pesee vide et plein. Masque XX-000-XX sauf si plate_free_format (RG-5.01). Normalisee serveur (trim/UPPER).',
|
||||
'plate_free_format' => '« Tout format » : desactive le masque XX-000-XX de l immatriculation (RG-5.01). Partage entre les 2 formulaires. Faux par defaut.',
|
||||
'empty_date' => 'Date/heure de la pesee a vide (tare). Defaut jour courant cote front (RG-5.07). Null tant que la pesee vide n est pas faite.',
|
||||
'empty_weight' => 'Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).',
|
||||
'empty_dsd' => 'Compteur DSD du pont a la pesee a vide. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).',
|
||||
'empty_mode' => 'Mode de la pesee a vide : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_empty_mode (RG-5.06).',
|
||||
'empty_manual_number' => 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a vide (RG-5.04).',
|
||||
'full_date' => 'Date/heure de la pesee a plein (brut). Null tant que la pesee plein n est pas faite.',
|
||||
'full_weight' => 'Poids a plein (brut) en kg — readonly UI, rempli par la pesee (RG-5.07).',
|
||||
'full_dsd' => 'Compteur DSD du pont a la pesee a plein. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).',
|
||||
'full_mode' => 'Mode de la pesee a plein : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_full_mode (RG-5.06).',
|
||||
'full_manual_number' => 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a plein (RG-5.04).',
|
||||
'net_weight' => 'Poids net = full_weight - empty_weight (kg), calcule serveur (RG-5.05). Null si une pesee manque. Colonne Poids de la liste.',
|
||||
'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,10 +56,25 @@ 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.',
|
||||
// Colonnes enum du ticket de pesee (M5) : le Choice borne deja les valeurs.
|
||||
'WeighingTicket::counterpartyType' => 'Choice {CLIENT,FOURNISSEUR,AUTRE} borne deja les valeurs.',
|
||||
'WeighingTicket::emptyMode' => 'Choice {AUTO,MANUAL} borne deja les valeurs.',
|
||||
'WeighingTicket::fullMode' => 'Choice {AUTO,MANUAL} borne deja les valeurs.',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -103,7 +118,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
}
|
||||
|
||||
/** @var Constraint $constraint */
|
||||
$constraint = $attribute->newInstance();
|
||||
$constraint = $attribute->newInstance();
|
||||
$messageProps = $this->messagePropertiesFor($constraint);
|
||||
|
||||
self::assertNotNull(
|
||||
@@ -174,6 +189,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
foreach ($constraints as $c) {
|
||||
if ($c instanceof Assert\Length) {
|
||||
$length = $c->max;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -245,7 +261,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 +335,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
|
||||
|
||||
@@ -79,7 +79,9 @@ final class SiteApiTest extends AbstractApiTestCase
|
||||
'name' => 'Test-New-Site',
|
||||
'street' => '1 rue du Test',
|
||||
'complement' => null,
|
||||
'postalCode' => '86000',
|
||||
// CP 75xxx -> code derive 75 : evite la collision uq_site_code
|
||||
// avec la fixture Chatellerault (code 86) — RG-5.02 (ERP-183).
|
||||
'postalCode' => '75000',
|
||||
'city' => 'Poitiers',
|
||||
'color' => '#AABBCC',
|
||||
],
|
||||
@@ -94,7 +96,7 @@ final class SiteApiTest extends AbstractApiTestCase
|
||||
public function testAdminCanPatchSite(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$site = new Site('Test-Patch-Site', '1 rue Test', null, '86000', 'Poitiers', '#000000');
|
||||
$site = new Site('Test-Patch-Site', '1 rue Test', null, '75000', 'Poitiers', '#000000');
|
||||
$em->persist($site);
|
||||
$em->flush();
|
||||
|
||||
@@ -112,7 +114,7 @@ final class SiteApiTest extends AbstractApiTestCase
|
||||
public function testAdminCanDeleteSite(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$site = new Site('Test-Delete-Site', '1 rue Test', null, '86000', 'Poitiers', '#000000');
|
||||
$site = new Site('Test-Delete-Site', '1 rue Test', null, '75000', 'Poitiers', '#000000');
|
||||
$em->persist($site);
|
||||
$em->flush();
|
||||
$siteId = $site->getId();
|
||||
@@ -129,7 +131,7 @@ final class SiteApiTest extends AbstractApiTestCase
|
||||
public function testUserWithViewButNotManageCannotDelete(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$site = new Site('Test-Protected', '1 rue Test', null, '86000', 'Poitiers', '#000000');
|
||||
$site = new Site('Test-Protected', '1 rue Test', null, '75000', 'Poitiers', '#000000');
|
||||
$em->persist($site);
|
||||
$em->flush();
|
||||
|
||||
@@ -189,7 +191,7 @@ final class SiteApiTest extends AbstractApiTestCase
|
||||
'json' => [
|
||||
'name' => 'Test-FullAddress-Ignored',
|
||||
'street' => '1 rue Test',
|
||||
'postalCode' => '86000',
|
||||
'postalCode' => '75000',
|
||||
'city' => 'Poitiers',
|
||||
'color' => '#000000',
|
||||
'fullAddress' => 'Adresse arbitraire envoyee par le client',
|
||||
@@ -200,7 +202,7 @@ final class SiteApiTest extends AbstractApiTestCase
|
||||
$data = $response->toArray();
|
||||
// Le getter computed prevaut sur ce qu'envoie le client : street
|
||||
// determine la 1re ligne, jamais la valeur "Adresse arbitraire...".
|
||||
self::assertSame("1 rue Test\n86000 Poitiers", $data['fullAddress']);
|
||||
self::assertSame("1 rue Test\n75000 Poitiers", $data['fullAddress']);
|
||||
}
|
||||
|
||||
public function testCreateSiteWithInvalidPostalCodeReturns422(): void
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport;
|
||||
|
||||
use App\Module\Transport\TransportModule;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests structurels du module Transport (M4) : identite et contrat
|
||||
* `permissions()` (socle RBAC, ERP-153).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class TransportModuleTest extends TestCase
|
||||
{
|
||||
public function testModuleIdentity(): void
|
||||
{
|
||||
self::assertSame('transport', TransportModule::ID);
|
||||
self::assertSame('Transport', TransportModule::LABEL);
|
||||
self::assertFalse(TransportModule::REQUIRED);
|
||||
}
|
||||
|
||||
public function testPermissionsSetContainsExactlyThreeCodes(): void
|
||||
{
|
||||
// Garde-fou : le jeu de permissions du module est fige par ce test. Si
|
||||
// quelqu'un ajoute / retire une permission sans ajuster la spec (§ 5.1)
|
||||
// ni la matrice RBAC (§ 5.2), le test casse explicitement.
|
||||
$codes = array_column(TransportModule::permissions(), 'code');
|
||||
sort($codes);
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
'transport.carriers.archive',
|
||||
'transport.carriers.manage',
|
||||
'transport.carriers.view',
|
||||
],
|
||||
$codes,
|
||||
);
|
||||
}
|
||||
|
||||
public function testEveryPermissionCodeIsPrefixedByModuleId(): void
|
||||
{
|
||||
// Convention de nommage `module.resource[.sub].action` : le prefixe doit
|
||||
// correspondre exactement a l'ID du module (verifie aussi par
|
||||
// app:sync-permissions).
|
||||
foreach (TransportModule::permissions() as $permission) {
|
||||
self::assertStringStartsWith(
|
||||
TransportModule::ID.'.',
|
||||
$permission['code'],
|
||||
'Chaque code de permission doit etre prefixe par l\'ID du module.',
|
||||
);
|
||||
self::assertNotSame('', $permission['label'], 'Chaque permission doit porter un label.');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user