Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4e550b5de | |||
| 10c113dbad | |||
| c0dadd79ff | |||
| 1ffa38282a | |||
| 036b075d5e | |||
| 25466b18d8 | |||
| 2fde5844e5 | |||
| 02a22597b3 | |||
| 76e7a59ba7 | |||
| e88bb059e6 | |||
| 312c119c06 | |||
| 8491f55072 | |||
| c63a5f971f | |||
| 5f2aa5334b | |||
| 21b1c64a5f | |||
| fd89160c4b | |||
| 8daf0ff5d4 | |||
| 87c53c354b | |||
| d304b74289 | |||
| 80b3741f64 | |||
| c468374b16 | |||
| 7ddf495d7f | |||
| 9fcf5c24f6 | |||
| 76fb01c063 | |||
| e76bd1dd63 | |||
| 498cef8cc0 | |||
| 7668d77c78 | |||
| 1d5110d000 | |||
| b6b5bb06e8 | |||
| fb9c15c52a | |||
| c371057c0b | |||
| e1712465f1 | |||
| 5125883e21 | |||
| 6ff5b13ce2 | |||
| 5bbd4ddb47 | |||
| a26bb09ee1 | |||
| 20296ac149 | |||
| 07e0bcbcce | |||
| fe1d012548 | |||
| d86dc69cf2 | |||
| 07ed57f283 | |||
| b5749520bc | |||
| 02d2fde653 | |||
| 0d284fe488 | |||
| 48ca963a9d | |||
| b11968f5e5 | |||
| 5109b5f57a | |||
| d5a01ac85f | |||
| 7adf3a511a | |||
| e612eae391 | |||
| f29266e5e8 | |||
| f27db02cb6 | |||
| 5765ba7178 |
@@ -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,
|
||||
];
|
||||
|
||||
@@ -24,13 +24,18 @@ doctrine:
|
||||
# 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_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:
|
||||
@@ -133,6 +138,17 @@ doctrine:
|
||||
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
|
||||
|
||||
|
||||
@@ -33,3 +33,14 @@ services:
|
||||
|
||||
App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
|
||||
alias: App\Module\Sites\Application\Service\CurrentSiteProvider
|
||||
|
||||
# M5 Logistique — pesee pont bascule (ERP-184)
|
||||
App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface:
|
||||
alias: App\Module\Logistique\Infrastructure\Weighbridge\RandomWeighbridgeReader
|
||||
|
||||
App\Module\Logistique\Application\Service\DsdAllocatorInterface:
|
||||
alias: App\Module\Logistique\Infrastructure\Service\DsdAllocator
|
||||
|
||||
# M5 Logistique — Provider/Processor ticket de pesee (ERP-185)
|
||||
App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface:
|
||||
alias: App\Module\Logistique\Infrastructure\Service\WeighingTicketNumberAllocator
|
||||
|
||||
@@ -78,6 +78,25 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Logistique" (M5, ERP-181) : nouveau pole "operations physiques sur
|
||||
// site", distinct du repertoire Transport (M4, desormais rattache a la section
|
||||
// Administration cote develop). 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.134'
|
||||
app.version: '0.1.144'
|
||||
|
||||
@@ -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 |
|
||||
+111
-18
@@ -39,6 +39,10 @@
|
||||
"section": "Transport",
|
||||
"carriers": "Répertoire transporteurs"
|
||||
},
|
||||
"logistique": {
|
||||
"section": "Logistique",
|
||||
"weighing_tickets": "Tickets de pesée"
|
||||
},
|
||||
"core": {
|
||||
"roles": "Gestion des rôles",
|
||||
"users": "Utilisateurs",
|
||||
@@ -528,7 +532,50 @@
|
||||
"exportError": "L'export du répertoire transporteurs a échoué. Réessayez.",
|
||||
"createSuccess": "Transporteur créé avec succès",
|
||||
"integrateSuccess": "Transporteur QUALIMAT intégré",
|
||||
"addressSaved": "Adresse enregistrée"
|
||||
"addressSaved": "Adresse enregistrée",
|
||||
"contactSaved": "Contact enregistré",
|
||||
"priceSaved": "Prix enregistré",
|
||||
"updateSuccess": "Transporteur mis à jour avec succès",
|
||||
"archiveSuccess": "Transporteur archivé avec succès",
|
||||
"restoreSuccess": "Transporteur restauré avec succès"
|
||||
},
|
||||
"action": {
|
||||
"edit": "Modifier",
|
||||
"archive": "Archiver",
|
||||
"restore": "Restaurer"
|
||||
},
|
||||
"consultation": {
|
||||
"title": "Consultation transporteur",
|
||||
"back": "Retour au répertoire",
|
||||
"loading": "Chargement du transporteur…",
|
||||
"notFound": "Transporteur introuvable.",
|
||||
"confirmArchive": {
|
||||
"title": "Archiver le transporteur",
|
||||
"message": "Ce transporteur n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
|
||||
},
|
||||
"confirmRestore": {
|
||||
"title": "Restaurer le transporteur",
|
||||
"message": "Ce transporteur réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
|
||||
},
|
||||
"price": {
|
||||
"group": "Type de transport",
|
||||
"carrier": "Transporteurs",
|
||||
"aproOrSite": "Adresse sites",
|
||||
"delivery": "Adresse livraisons",
|
||||
"forfait": "Forfait (€)",
|
||||
"tonne": "Tonne (€)",
|
||||
"indexation": "Indexation",
|
||||
"state": "État du prix",
|
||||
"export": "Exporter",
|
||||
"empty": "Aucun prix pour ce transporteur."
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le transporteur",
|
||||
"back": "Retour à la consultation",
|
||||
"loading": "Chargement du transporteur…",
|
||||
"notFound": "Transporteur introuvable.",
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"containerType": {
|
||||
"BENNE": "Benne",
|
||||
@@ -578,7 +625,8 @@
|
||||
"dischargeRequired": "La décharge est obligatoire pour une certification « Autre ».",
|
||||
"indexationRequired": "Le taux d'indexation est obligatoire pour un transporteur affrété.",
|
||||
"containerTypeRequired": "Le type de contenant est obligatoire pour un transporteur affrété.",
|
||||
"volumeRequired": "Le volume est obligatoire pour un transporteur affrété."
|
||||
"volumeRequired": "Le volume est obligatoire pour un transporteur affrété.",
|
||||
"uploadFailed": "Le téléversement de la décharge a échoué."
|
||||
},
|
||||
"address": {
|
||||
"country": "Pays",
|
||||
@@ -587,15 +635,59 @@
|
||||
"street": "Adresse",
|
||||
"streetComplement": "Adresse complémentaire",
|
||||
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
|
||||
"add": "Nouvelle adresse",
|
||||
"remove": "Supprimer l'adresse",
|
||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||
},
|
||||
"contact": {
|
||||
"lastName": "Nom",
|
||||
"firstName": "Prénom",
|
||||
"jobTitle": "Fonction",
|
||||
"phonePrimary": "Téléphone",
|
||||
"phoneSecondary": "Téléphone (2)",
|
||||
"addPhone": "Ajouter un numéro",
|
||||
"email": "Email",
|
||||
"add": "Nouveau contact",
|
||||
"remove": "Supprimer le contact"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Supprimer ce bloc",
|
||||
"message": "Cette suppression est définitive. Confirmer ?",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Supprimer"
|
||||
},
|
||||
"price": {
|
||||
"direction": "Sens",
|
||||
"directionClient": "Client",
|
||||
"directionSupplier": "Fournisseur",
|
||||
"client": "Client",
|
||||
"clientDeliveryAddress": "Adresse de livraison",
|
||||
"departureSite": "Adresse de départ",
|
||||
"supplier": "Fournisseur",
|
||||
"supplierSupplyAddress": "Adresse d'approvisionnement",
|
||||
"deliverySite": "Adresse de livraison",
|
||||
"containerType": "Benne / Fond mouvant",
|
||||
"pricingUnit": "Forfait / Tonne",
|
||||
"pricingForfait": "Forfait",
|
||||
"pricingTonne": "Tonne",
|
||||
"price": "Prix",
|
||||
"priceState": "État du prix",
|
||||
"stateEnCours": "En cours",
|
||||
"stateValide": "Validé",
|
||||
"stateNonValide": "Non validé",
|
||||
"add": "Nouveau prix",
|
||||
"remove": "Supprimer le prix",
|
||||
"errors": {
|
||||
"direction": "Le sens du prix est obligatoire.",
|
||||
"client": "Le client est obligatoire pour un prix client.",
|
||||
"clientDeliveryAddress": "L'adresse de livraison du client est obligatoire pour un prix client.",
|
||||
"departureSite": "Le site de départ est obligatoire pour un prix client.",
|
||||
"supplier": "Le fournisseur est obligatoire pour un prix fournisseur.",
|
||||
"supplierSupplyAddress": "L'adresse d'approvisionnement est obligatoire pour un prix fournisseur.",
|
||||
"deliverySite": "Le site de livraison est obligatoire pour un prix fournisseur.",
|
||||
"containerType": "Le type de contenant est obligatoire.",
|
||||
"pricingUnit": "L'unité de tarification est obligatoire.",
|
||||
"price": "Le prix est obligatoire.",
|
||||
"priceState": "L'état du prix est obligatoire."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -642,27 +734,28 @@
|
||||
"delete": "Suppression"
|
||||
},
|
||||
"entity": {
|
||||
"core_user": "Utilisateur",
|
||||
"core_role": "Rôle",
|
||||
"core_permission": "Permission",
|
||||
"sites_site": "Site",
|
||||
"catalog_category": "Catégorie",
|
||||
"commercial_client": "Client",
|
||||
"core_user": "Utilisateur",
|
||||
"core_role": "Rôle",
|
||||
"core_permission": "Permission",
|
||||
"sites_site": "Site",
|
||||
"catalog_category": "Catégorie",
|
||||
"commercial_client": "Client",
|
||||
"commercial_clientaddress": "Adresse client",
|
||||
"commercial_clientcontact": "Contact client",
|
||||
"commercial_clientrib": "RIB client",
|
||||
"commercial_supplier": "Fournisseur",
|
||||
"commercial_clientrib": "RIB client",
|
||||
"commercial_supplier": "Fournisseur",
|
||||
"commercial_supplieraddress": "Adresse fournisseur",
|
||||
"commercial_suppliercontact": "Contact fournisseur",
|
||||
"commercial_supplierrib": "RIB fournisseur",
|
||||
"technique_provider": "Prestataire",
|
||||
"technique_provider": "Prestataire",
|
||||
"technique_provideraddress": "Adresse prestataire",
|
||||
"technique_providercontact": "Contact prestataire",
|
||||
"technique_providerrib": "RIB prestataire",
|
||||
"transport_carrier": "Transporteur",
|
||||
"transport_carrieraddress": "Adresse transporteur",
|
||||
"transport_carriercontact": "Contact transporteur",
|
||||
"transport_carrierprice": "Prix transporteur"
|
||||
"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({})
|
||||
@@ -1,15 +1,6 @@
|
||||
<template>
|
||||
<!-- Adresse UNIQUE par transporteur (ERP-172) : un seul bloc, jamais supprimable. -->
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : modal de confirmation cote parent. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('transport.carriers.form.address.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<!-- Pays : prerempli « France » (RG-4.05). -->
|
||||
<MalioSelect
|
||||
:model-value="model.country"
|
||||
@@ -114,7 +105,6 @@ const props = defineProps<{
|
||||
modelValue: CarrierAddressFormDraft
|
||||
/** Pays disponibles (France par defaut). */
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
readonly?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
@@ -122,7 +112,6 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: CarrierAddressFormDraft]
|
||||
'remove': []
|
||||
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
|
||||
'degraded': []
|
||||
}>()
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : ouvre une modal de confirmation côté parent. Masquée si
|
||||
non supprimable (1er bloc) ou en lecture seule. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('transport.carriers.form.contact.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.lastName"
|
||||
:label="t('transport.carriers.form.contact.lastName')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="model.firstName"
|
||||
:label="t('transport.carriers.form.contact.firstName')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false)
|
||||
renvoie `class` sur l'input interne, pas sur la cellule de grille. -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('transport.carriers.form.contact.jobTitle')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
:model-value="model.email"
|
||||
:label="t('transport.carriers.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<!-- Téléphone principal + bouton « + » révélant le 2e numéro (max 2). -->
|
||||
<MalioInputPhone
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('transport.carriers.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('transport.carriers.form.contact.addPhone')"
|
||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<!-- 2e numéro : révélé à la demande (max 2 téléphones — RG-4.08). -->
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('transport.carriers.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm'
|
||||
|
||||
// Masque téléphone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
|
||||
const PHONE_MASK = '## ## ## ## ##'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Brouillon du contact (v-model). */
|
||||
modelValue: CarrierContactFormDraft
|
||||
/** Affiche l'icône de suppression (1er bloc non supprimable). */
|
||||
removable?: boolean
|
||||
/** Bloc en lecture seule (onglet validé). */
|
||||
readonly?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexées par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: CarrierContactFormDraft]
|
||||
'remove': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Alias local pour la lisibilité du template.
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
/** Émet un nouveau brouillon avec le champ modifié (immutabilité). */
|
||||
function update<K extends keyof CarrierContactFormDraft>(field: K, value: CarrierContactFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/** Révèle le 2e numéro (max 1 secondaire, le « + » disparaît). */
|
||||
function revealSecondaryPhone(): void {
|
||||
emit('update:modelValue', { ...props.modelValue, hasSecondaryPhone: true })
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : modal de confirmation côté parent. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('transport.carriers.form.price.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<!-- RG-4.09 : sens du prix (CLIENT / FOURNISSEUR) en colonne 1 / ligne 1, radios
|
||||
EN LIGNE (horizontaux), centrés sur la hauteur de champ (h-12) comme la
|
||||
case « Affréter ». Pas de label de groupe. -->
|
||||
<div>
|
||||
<div class="flex h-12 items-center gap-6">
|
||||
<MalioRadioButton
|
||||
:model-value="model.direction"
|
||||
:name="`price-direction-${uid}`"
|
||||
value="CLIENT"
|
||||
:label="t('transport.carriers.form.price.directionClient')"
|
||||
:disabled="readonly"
|
||||
group-class="mt-0"
|
||||
@update:model-value="onDirectionChange"
|
||||
/>
|
||||
<MalioRadioButton
|
||||
:model-value="model.direction"
|
||||
:name="`price-direction-${uid}`"
|
||||
value="FOURNISSEUR"
|
||||
:label="t('transport.carriers.form.price.directionSupplier')"
|
||||
:disabled="readonly"
|
||||
group-class="mt-0"
|
||||
@update:model-value="onDirectionChange"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors?.direction" class="ml-[2px] text-xs text-m-danger">{{ errors.direction }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Branche CLIENT (RG-4.10). -->
|
||||
<template v-if="model.direction === 'CLIENT'">
|
||||
<MalioSelect
|
||||
:model-value="model.clientIri"
|
||||
:options="clientOptions"
|
||||
:label="t('transport.carriers.form.price.client')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:error="errors?.client"
|
||||
@update:model-value="onClientChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="model.clientDeliveryAddressIri"
|
||||
:options="clientAddressOptions"
|
||||
:label="t('transport.carriers.form.price.clientDeliveryAddress')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:error="errors?.clientDeliveryAddress"
|
||||
@update:model-value="(v: string | number | null) => update('clientDeliveryAddressIri', v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="model.departureSiteIri"
|
||||
:options="siteOptions"
|
||||
:label="t('transport.carriers.form.price.departureSite')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:error="errors?.departureSite"
|
||||
@update:model-value="(v: string | number | null) => update('departureSiteIri', v === null ? null : String(v))"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Branche FOURNISSEUR (RG-4.11). -->
|
||||
<template v-else-if="model.direction === 'FOURNISSEUR'">
|
||||
<MalioSelect
|
||||
:model-value="model.supplierIri"
|
||||
:options="supplierOptions"
|
||||
:label="t('transport.carriers.form.price.supplier')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:error="errors?.supplier"
|
||||
@update:model-value="onSupplierChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="model.supplierSupplyAddressIri"
|
||||
:options="supplierAddressOptions"
|
||||
:label="t('transport.carriers.form.price.supplierSupplyAddress')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:error="errors?.supplierSupplyAddress"
|
||||
@update:model-value="(v: string | number | null) => update('supplierSupplyAddressIri', v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="model.deliverySiteIri"
|
||||
:options="siteOptions"
|
||||
:label="t('transport.carriers.form.price.deliverySite')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:error="errors?.deliverySite"
|
||||
@update:model-value="(v: string | number | null) => update('deliverySiteIri', v === null ? null : String(v))"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Communs (visibles dès qu'un sens est choisi). -->
|
||||
<template v-if="model.direction !== null">
|
||||
<!-- Contenant : Benne / Fond mouvant (radios centrés h-12, pas de label). -->
|
||||
<div>
|
||||
<div class="flex h-12 items-center gap-4">
|
||||
<MalioRadioButton
|
||||
:model-value="model.containerType"
|
||||
:name="`price-container-${uid}`"
|
||||
value="BENNE"
|
||||
:label="t('transport.carriers.containerType.BENNE')"
|
||||
:disabled="readonly"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioRadioButton
|
||||
:model-value="model.containerType"
|
||||
:name="`price-container-${uid}`"
|
||||
value="FOND_MOUVANT"
|
||||
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
||||
:disabled="readonly"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors?.containerType" class="ml-[2px] text-xs text-m-danger">{{ errors.containerType }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Tarification : Forfait / Tonne (radios centrés h-12, pas de label). -->
|
||||
<div>
|
||||
<div class="flex h-12 items-center gap-4">
|
||||
<MalioRadioButton
|
||||
:model-value="model.pricingUnit"
|
||||
:name="`price-unit-${uid}`"
|
||||
value="FORFAIT"
|
||||
:label="t('transport.carriers.form.price.pricingForfait')"
|
||||
:disabled="readonly"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioRadioButton
|
||||
:model-value="model.pricingUnit"
|
||||
:name="`price-unit-${uid}`"
|
||||
value="TONNE"
|
||||
:label="t('transport.carriers.form.price.pricingTonne')"
|
||||
:disabled="readonly"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors?.pricingUnit" class="ml-[2px] text-xs text-m-danger">{{ errors.pricingUnit }}</p>
|
||||
</div>
|
||||
|
||||
<MalioInputAmount
|
||||
:model-value="model.price"
|
||||
:label="t('transport.carriers.form.price.price')"
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:error="errors?.price"
|
||||
@update:model-value="(v: string) => update('price', v)"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
:model-value="model.priceState"
|
||||
:options="priceStateOptions"
|
||||
:label="t('transport.carriers.form.price.priceState')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:error="errors?.priceState"
|
||||
@update:model-value="(v: string | number | null) => update('priceState', v === null ? null : String(v))"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useId, watch } from 'vue'
|
||||
import type { CarrierPriceFormDraft } from '~/modules/transport/types/carrierForm'
|
||||
|
||||
interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
/** Brouillon du prix (v-model). */
|
||||
modelValue: CarrierPriceFormDraft
|
||||
/** Clients disponibles (IRI en value). */
|
||||
clientOptions: SelectOption[]
|
||||
/** Fournisseurs disponibles (IRI en value). */
|
||||
supplierOptions: SelectOption[]
|
||||
/** Sites Starseed (3 sites — IRI en value). */
|
||||
siteOptions: SelectOption[]
|
||||
removable?: boolean
|
||||
readonly?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexées par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: CarrierPriceFormDraft]
|
||||
'remove': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
|
||||
// Identifiant unique par instance : les groupes de radios (sens / contenant / tarif)
|
||||
// doivent avoir un `name` PROPRE à chaque bloc prix, sinon plusieurs blocs partagent
|
||||
// le même groupe HTML et leurs radios se désélectionnent mutuellement.
|
||||
const uid = useId()
|
||||
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
const priceStateOptions = computed<SelectOption[]>(() => [
|
||||
{ value: 'EN_COURS', label: t('transport.carriers.form.price.stateEnCours') },
|
||||
{ value: 'VALIDE', label: t('transport.carriers.form.price.stateValide') },
|
||||
{ value: 'NON_VALIDE', label: t('transport.carriers.form.price.stateNonValide') },
|
||||
])
|
||||
|
||||
// Adresses chargées à la volée pour le client / fournisseur sélectionné (par bloc).
|
||||
const clientAddressOptions = ref<SelectOption[]>([])
|
||||
const supplierAddressOptions = ref<SelectOption[]>([])
|
||||
|
||||
/** Émet un nouveau brouillon avec le champ modifié (immutabilité). */
|
||||
function update<K extends keyof CarrierPriceFormDraft>(field: K, value: CarrierPriceFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/** Changement de sens : réinitialise les DEUX branches (cohérence CHECK BDD). */
|
||||
function onDirectionChange(value: string | number | boolean | null): void {
|
||||
const direction = value === 'CLIENT' || value === 'FOURNISSEUR' ? value : null
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
direction,
|
||||
clientIri: null,
|
||||
clientDeliveryAddressIri: null,
|
||||
departureSiteIri: null,
|
||||
supplierIri: null,
|
||||
supplierSupplyAddressIri: null,
|
||||
deliverySiteIri: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** Sélection d'un client → maj IRI + reset de l'adresse de livraison (autre client). */
|
||||
function onClientChange(value: string | number | null): void {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
clientIri: value === null ? null : String(value),
|
||||
clientDeliveryAddressIri: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** Sélection d'un fournisseur → maj IRI + reset de l'adresse d'appro. */
|
||||
function onSupplierChange(value: string | number | null): void {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
supplierIri: value === null ? null : String(value),
|
||||
supplierSupplyAddressIri: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** Réponse détail (client / fournisseur) embarquant ses adresses. */
|
||||
interface ParentWithAddresses {
|
||||
addresses?: { '@id': string, street?: string | null, postalCode?: string | null, city?: string | null }[]
|
||||
}
|
||||
|
||||
/** Mappe les adresses embarquées en options (IRI en value, voie · CP · ville en label). */
|
||||
function toAddressOptions(parent: ParentWithAddresses): SelectOption[] {
|
||||
return (parent.addresses ?? []).map(a => ({
|
||||
value: a['@id'],
|
||||
label: [a.street, a.postalCode, a.city].filter(Boolean).join(' · ') || a['@id'],
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge les adresses d'un parent (client/fournisseur) à la volée via son détail
|
||||
* (GET de l'IRI, qui embarque `addresses`). Échec → liste vide (non bloquant).
|
||||
*/
|
||||
async function loadAddresses(iri: string | null, target: typeof clientAddressOptions): Promise<void> {
|
||||
if (!iri) {
|
||||
target.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
// L'IRI est absolue (/api/clients/5) ; useApi préfixe déjà /api → on le retire.
|
||||
const path = iri.replace(/^\/api/, '')
|
||||
const data = await api.get<ParentWithAddresses>(path, {}, { headers: { Accept: 'application/ld+json' }, toast: false })
|
||||
target.value = toAddressOptions(data)
|
||||
}
|
||||
catch {
|
||||
target.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// Recharge les adresses quand le client / fournisseur change (immediate pour le
|
||||
// pré-remplissage en édition).
|
||||
watch(() => props.modelValue.clientIri, iri => loadAddresses(iri, clientAddressOptions), { immediate: true })
|
||||
watch(() => props.modelValue.supplierIri, iri => loadAddresses(iri, supplierAddressOptions), { immediate: true })
|
||||
</script>
|
||||
@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { debounce } from '~/shared/utils/debounce'
|
||||
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||
|
||||
/**
|
||||
* Onglet « Qualimat » — saisie assistée par recherche dans le référentiel QUALIMAT
|
||||
* (RG-4.01 / RG-4.04). Datatable paginé filtré par le NOM (`searchName`), sélection
|
||||
* d'une ligne → modal de confirmation → `integrate`. Mutualisé entre l'écran
|
||||
* d'AJOUT (ERP-166) et l'écran de MODIFICATION (ERP-172, « actualiser le
|
||||
* transporteur »). La persistance (copie nom / certification / FK) est portée par
|
||||
* le parent via `useCarrierForm.applyQualimatSelection`.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** Terme de recherche (nom du transporteur saisi dans le formulaire principal). */
|
||||
searchName: string
|
||||
/** IRI QUALIMAT actuellement lié (coche le radio de la ligne correspondante). */
|
||||
selectedIri: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'integrate', row: QualimatCarrierRow): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
items: qualimatItems,
|
||||
totalItems: qualimatTotal,
|
||||
currentPage: qualimatPage,
|
||||
itemsPerPage: qualimatPerPage,
|
||||
itemsPerPageOptions: qualimatPerPageOptions,
|
||||
goToPage: qualimatGoToPage,
|
||||
setItemsPerPage: qualimatSetPerPage,
|
||||
setFilters: qualimatSetFilters,
|
||||
} = useQualimatSearch()
|
||||
|
||||
// Colonnes du datatable de sélection QUALIMAT (radio / Nom / Adresse / Validité).
|
||||
const qualimatColumns = [
|
||||
{ key: 'select', label: '' },
|
||||
{ key: 'name', label: t('transport.carriers.form.qualimat.columns.name') },
|
||||
{ key: 'address', label: t('transport.carriers.form.qualimat.columns.address') },
|
||||
{ key: 'validityDate', label: t('transport.carriers.form.qualimat.columns.validityDate') },
|
||||
]
|
||||
|
||||
// Le datatable n'affiche QUE des résultats de recherche : vide tant que le Nom n'est
|
||||
// pas saisi (pas de liste complète par défaut).
|
||||
const hasQualimatSearch = computed(() => props.searchName.trim() !== '')
|
||||
|
||||
const qualimatRows = computed(() => {
|
||||
if (!hasQualimatSearch.value) {
|
||||
return []
|
||||
}
|
||||
return qualimatItems.value.map(row => ({
|
||||
id: row.id,
|
||||
iri: row['@id'],
|
||||
name: row.name,
|
||||
address: formatQualimatAddress(row),
|
||||
validityDate: row.validityDate,
|
||||
}))
|
||||
})
|
||||
|
||||
const qualimatTotalDisplay = computed(() => (hasQualimatSearch.value ? qualimatTotal.value : 0))
|
||||
const qualimatEmptyMessage = computed(() => hasQualimatSearch.value
|
||||
? t('transport.carriers.form.qualimat.empty')
|
||||
: t('transport.carriers.form.qualimat.searchHint'))
|
||||
|
||||
// Re-filtrage debouncé sur le nom ; aucune recherche tant que le Nom est vide.
|
||||
const filterQualimatByName = debounce((term: string) => {
|
||||
if (term.trim() === '') {
|
||||
return
|
||||
}
|
||||
void qualimatSetFilters({ search: term })
|
||||
}, 300)
|
||||
|
||||
watch(() => props.searchName, term => filterQualimatByName(term), { immediate: true })
|
||||
|
||||
/** Adresse QUALIMAT condensée pour la colonne « Adresse » (voie · CP · ville). */
|
||||
function formatQualimatAddress(row: QualimatCarrierRow): string {
|
||||
return [row.address, row.postalCode, row.city].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
/** RG-4.04 : un agrément est périmé si sa date de validité est < aujourd'hui. */
|
||||
function isExpired(value: string): boolean {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return false
|
||||
}
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date.getTime() < today.getTime()
|
||||
}
|
||||
|
||||
/** Format court français JJ-MM-AAAA (chaîne vide si date absente / invalide). */
|
||||
function formatDateFr(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
return `${day}-${month}-${date.getFullYear()}`
|
||||
}
|
||||
|
||||
// ── Confirmation d'intégration ───────────────────────────────────────────────
|
||||
const confirmOpen = ref(false)
|
||||
const pendingRow = ref<QualimatCarrierRow | null>(null)
|
||||
|
||||
/** Clic sur une ligne → retrouve la ligne QUALIMAT source + ouvre la modal. */
|
||||
function onQualimatRowClick(item: Record<string, unknown>): void {
|
||||
const row = qualimatItems.value.find(r => r.id === item.id)
|
||||
if (row) {
|
||||
pendingRow.value = row
|
||||
confirmOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Confirme l'intégration : délègue la persistance au parent via `integrate`. */
|
||||
function confirmIntegrate(): void {
|
||||
const row = pendingRow.value
|
||||
confirmOpen.value = false
|
||||
if (row !== null) {
|
||||
emit('integrate', row)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- table-fixed : 1re colonne (radio) étroite, les 3 autres à parts égales. -->
|
||||
<MalioDataTable
|
||||
class="qualimat-table"
|
||||
table-class="table-fixed"
|
||||
:columns="qualimatColumns"
|
||||
:items="qualimatRows"
|
||||
:total-items="qualimatTotalDisplay"
|
||||
:page="qualimatPage"
|
||||
:per-page="qualimatPerPage"
|
||||
:per-page-options="qualimatPerPageOptions"
|
||||
row-clickable
|
||||
:empty-message="qualimatEmptyMessage"
|
||||
@row-click="onQualimatRowClick"
|
||||
@update:page="qualimatGoToPage"
|
||||
@update:per-page="qualimatSetPerPage"
|
||||
>
|
||||
<!-- Radio reflétant la ligne QUALIMAT intégrée (lecture). -->
|
||||
<template #cell-select="{ item }">
|
||||
<MalioRadioButton
|
||||
:model-value="selectedIri"
|
||||
name="qualimat-row"
|
||||
:value="item.iri"
|
||||
group-class="mt-0"
|
||||
/>
|
||||
</template>
|
||||
<!-- Date de validité : fond rouge si périmée (RG-4.04). -->
|
||||
<template #cell-validityDate="{ item }">
|
||||
<span
|
||||
v-if="item.validityDate"
|
||||
:class="isExpired(item.validityDate as string) ? 'inline-block rounded px-2 py-0.5 bg-m-danger text-white' : ''"
|
||||
>
|
||||
{{ formatDateFr(item.validityDate as string) }}
|
||||
</span>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<!-- Modal de confirmation d'intégration QUALIMAT. -->
|
||||
<MalioModal v-model="confirmOpen" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.qualimat.confirm.title') }}</h2>
|
||||
</template>
|
||||
<p>{{ t('transport.carriers.form.qualimat.confirm.message') }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('transport.carriers.form.qualimat.confirm.cancel')"
|
||||
@click="confirmOpen = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
button-class="flex-1"
|
||||
:label="t('transport.carriers.form.qualimat.confirm.confirm')"
|
||||
@click="confirmIntegrate"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Datatable QUALIMAT en table-fixed : la colonne radio (1re) reste étroite,
|
||||
les 3 autres (nom / adresse / validité) se partagent l'espace à parts égales. */
|
||||
.qualimat-table :deep(th:first-child),
|
||||
.qualimat-table :deep(td:first-child) {
|
||||
width: 56px;
|
||||
}
|
||||
</style>
|
||||
@@ -37,6 +37,9 @@ vi.stubGlobal('useToast', () => ({
|
||||
}))
|
||||
|
||||
const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm')
|
||||
const { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } = await import('../../utils/forms/carrierContact')
|
||||
const { buildCarrierPricePayload, isCarrierPriceValid } = await import('../../utils/forms/carrierPrice')
|
||||
const { emptyCarrierContact, emptyCarrierPrice } = await import('../../types/carrierForm')
|
||||
|
||||
describe('useCarrierForm', () => {
|
||||
beforeEach(() => {
|
||||
@@ -108,6 +111,8 @@ describe('useCarrierForm', () => {
|
||||
form.main.name = 'Acme'
|
||||
form.main.certificationType = 'GMP_PLUS'
|
||||
form.main.isChartered = true
|
||||
// Annule le défaut « BENNE » pour vérifier la garde « contenant obligatoire ».
|
||||
form.main.containerType = null
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
@@ -320,16 +325,18 @@ describe('useCarrierForm — champs conditionnels (ERP-166)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('RG-4.03 affrété mais champs vides : omis du payload (422 NotBlank back)', () => {
|
||||
it('RG-4.03 affrété, indexation/volume vides : omis du payload (containerType garde son défaut BENNE)', () => {
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Acme'
|
||||
form.main.certificationType = 'GMP_PLUS'
|
||||
form.main.isChartered = true
|
||||
|
||||
// indexation / volume vides → omis (422 NotBlank back) ; containerType défaut « BENNE » envoyé.
|
||||
expect(form.buildMainPayload()).toEqual({
|
||||
name: 'Acme',
|
||||
certificationType: 'GMP_PLUS',
|
||||
isChartered: true,
|
||||
containerType: 'BENNE',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -343,6 +350,52 @@ describe('useCarrierForm — champs conditionnels (ERP-166)', () => {
|
||||
form.main.dischargeDocumentIri = '/api/uploaded_documents/7'
|
||||
expect(form.buildMainPayload()).toMatchObject({ dischargeDocument: '/api/uploaded_documents/7' })
|
||||
})
|
||||
|
||||
it('RG-4.02 upload différé : selectDischarge ne POST pas ; submitMain upload PUIS crée', async () => {
|
||||
mockPost.mockReset()
|
||||
// 1er POST = /uploaded_documents (renvoie l'IRI) ; 2e = /carriers (création).
|
||||
mockPost
|
||||
.mockResolvedValueOnce({ '@id': '/api/uploaded_documents/7' })
|
||||
.mockResolvedValueOnce({ id: 12, name: 'ACME', certificationType: 'AUTRE' })
|
||||
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Acme'
|
||||
form.main.certificationType = 'AUTRE'
|
||||
|
||||
// Sélection du fichier : aucun appel réseau (upload différé à l'enregistrement).
|
||||
form.selectDischarge(new File(['x'], 'decharge.pdf', { type: 'application/pdf' }))
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
// La validation est satisfaite par le fichier en attente (pas encore d'IRI).
|
||||
expect(form.mainErrors.errors.dischargeDocument).toBeUndefined()
|
||||
|
||||
const created = await form.submitMain()
|
||||
expect(created).toBe(true)
|
||||
|
||||
// 1er appel : upload multipart ; 2e : création carrier avec l'IRI résolu.
|
||||
expect(mockPost.mock.calls[0][0]).toBe('/uploaded_documents')
|
||||
expect(mockPost.mock.calls[1][0]).toBe('/carriers')
|
||||
expect(mockPost.mock.calls[1][1]).toMatchObject({ dischargeDocument: '/api/uploaded_documents/7' })
|
||||
})
|
||||
|
||||
it('RG-4.02 upload différé : un 422 MIME bloque la création (message inline, pas de POST /carriers)', async () => {
|
||||
mockPost.mockReset()
|
||||
// Le POST /uploaded_documents échoue (MIME hors whitelist) → 422.
|
||||
mockPost.mockRejectedValueOnce(Object.assign(new Error('422'), {
|
||||
data: { 'hydra:description': 'Type de fichier non autorisé.' },
|
||||
}))
|
||||
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Acme'
|
||||
form.main.certificationType = 'AUTRE'
|
||||
form.selectDischarge(new File(['x'], 'malware.exe', { type: 'application/x-msdownload' }))
|
||||
|
||||
const created = await form.submitMain()
|
||||
expect(created).toBe(false)
|
||||
// Message back affiché inline sous le champ ; aucune création de carrier.
|
||||
expect(form.mainErrors.errors.dischargeDocument).toBe('Type de fichier non autorisé.')
|
||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||
expect(mockPost.mock.calls[0][0]).toBe('/uploaded_documents')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => {
|
||||
@@ -433,14 +486,13 @@ describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('applyQualimatSelection pré-remplit le 1er bloc d\'adresse (RG-4.05)', async () => {
|
||||
it('applyQualimatSelection pré-remplit l\'adresse unique à la création (RG-4.05)', async () => {
|
||||
const form = useCarrierForm()
|
||||
form.main.name = 'Acme'
|
||||
|
||||
await form.applyQualimatSelection(QUALIMAT_ROW)
|
||||
|
||||
expect(form.addresses.value).toHaveLength(1)
|
||||
expect(form.addresses.value[0]).toEqual({
|
||||
expect(form.address.value).toEqual({
|
||||
id: null,
|
||||
country: 'France',
|
||||
postalCode: '86000',
|
||||
@@ -451,53 +503,38 @@ describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCarrierForm — onglet Adresses (ERP-167)', () => {
|
||||
describe('useCarrierForm — onglet Adresse (ERP-167 / ERP-172 : adresse unique)', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
mockDelete.mockReset()
|
||||
})
|
||||
|
||||
/** Transporteur créé, onglet Adresses accessible. */
|
||||
/** Transporteur créé, onglet Adresse accessible. */
|
||||
function createdForm() {
|
||||
const form = useCarrierForm()
|
||||
form.carrierId.value = 7
|
||||
return form
|
||||
}
|
||||
|
||||
/** Remplit un bloc adresse complet (CP + ville + rue). */
|
||||
function fillAddress(form: ReturnType<typeof useCarrierForm>, index = 0): void {
|
||||
const a = form.addresses.value[index]
|
||||
if (a) {
|
||||
a.postalCode = '86100'
|
||||
a.city = 'Châtellerault'
|
||||
a.street = '1 rue du Test'
|
||||
}
|
||||
/** Remplit l'unique bloc adresse (CP + ville + rue). */
|
||||
function fillAddress(form: ReturnType<typeof useCarrierForm>): void {
|
||||
const a = form.address.value
|
||||
a.postalCode = '86100'
|
||||
a.city = 'Châtellerault'
|
||||
a.street = '1 rue du Test'
|
||||
}
|
||||
|
||||
it('canAddAddress : désactivé tant que la dernière adresse est incomplète', () => {
|
||||
const form = createdForm()
|
||||
expect(form.canAddAddress.value).toBe(false)
|
||||
|
||||
form.addAddress()
|
||||
expect(form.addresses.value).toHaveLength(1) // no-op tant qu'incomplète
|
||||
|
||||
fillAddress(form)
|
||||
expect(form.canAddAddress.value).toBe(true)
|
||||
form.addAddress()
|
||||
expect(form.addresses.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('submitAddresses : POST des nouvelles adresses, capture l\'id, finalise l\'onglet', async () => {
|
||||
it('submitAddress : POST sur /carriers/{id}/address, capture l\'id, finalise l\'onglet', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 88 })
|
||||
const form = createdForm()
|
||||
fillAddress(form)
|
||||
|
||||
const ok = await form.submitAddresses(vi.fn())
|
||||
const ok = await form.submitAddress(vi.fn())
|
||||
|
||||
expect(ok).toBe(true)
|
||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||
expect(url).toBe('/carriers/7/addresses')
|
||||
expect(url).toBe('/carriers/7/address')
|
||||
expect(body).toEqual({
|
||||
country: 'France',
|
||||
postalCode: '86100',
|
||||
@@ -506,24 +543,23 @@ describe('useCarrierForm — onglet Adresses (ERP-167)', () => {
|
||||
streetComplement: null,
|
||||
})
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
expect(form.addresses.value[0]?.id).toBe(88)
|
||||
expect(form.address.value.id).toBe(88)
|
||||
expect(form.isValidated('addresses')).toBe(true)
|
||||
})
|
||||
|
||||
it('submitAddresses : PATCH des adresses existantes sur /carrier_addresses/{id}', async () => {
|
||||
it('submitAddress : PATCH de l\'adresse existante sur /carrier_addresses/{id}', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
fillAddress(form)
|
||||
const first = form.addresses.value[0]
|
||||
if (first) first.id = 88
|
||||
form.address.value.id = 88
|
||||
|
||||
await form.submitAddresses(vi.fn())
|
||||
await form.submitAddress(vi.fn())
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith('/carrier_addresses/88', expect.objectContaining({ city: 'Châtellerault' }), { toast: false })
|
||||
})
|
||||
|
||||
it('submitAddresses : mappe les 422 PAR LIGNE et ne finalise pas l\'onglet (RG-4.05)', async () => {
|
||||
it('submitAddress : mappe les 422 inline par champ et ne finalise pas l\'onglet (RG-4.05)', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
@@ -533,25 +569,479 @@ describe('useCarrierForm — onglet Adresses (ERP-167)', () => {
|
||||
const form = createdForm()
|
||||
fillAddress(form)
|
||||
|
||||
const ok = await form.submitAddresses(vi.fn())
|
||||
const ok = await form.submitAddress(vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire pour un transporteur affrété.')
|
||||
expect(form.addressErrors.value.city).toBe('La ville est obligatoire pour un transporteur affrété.')
|
||||
expect(form.isValidated('addresses')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('removeAddress : DELETE /carrier_addresses/{id} puis retrait du bloc', async () => {
|
||||
mockDelete.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
fillAddress(form)
|
||||
const first = form.addresses.value[0]
|
||||
if (first) first.id = 88
|
||||
form.addAddress()
|
||||
fillAddress(form, 1)
|
||||
describe('carrierContact (util) — validité alignée M1/M2/M3 + max 2 téléphones', () => {
|
||||
it('isCarrierContactBlank : vrai si vide, faux dès un champ comptant rempli (phoneSecondary exclu)', () => {
|
||||
expect(isCarrierContactBlank(emptyCarrierContact())).toBe(true)
|
||||
expect(isCarrierContactBlank({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false)
|
||||
expect(isCarrierContactBlank({ ...emptyCarrierContact(), phonePrimary: '0102030405' })).toBe(false)
|
||||
// phoneSecondary seul ne compte pas (aligné M1/M2/M3).
|
||||
expect(isCarrierContactBlank({ ...emptyCarrierContact(), phoneSecondary: '0605040302', hasSecondaryPhone: true })).toBe(true)
|
||||
})
|
||||
|
||||
await form.removeAddress(0)
|
||||
it('isCarrierContactNamed : nommé seulement avec un prénom OU un nom', () => {
|
||||
expect(isCarrierContactNamed(emptyCarrierContact())).toBe(false)
|
||||
expect(isCarrierContactNamed({ ...emptyCarrierContact(), firstName: 'Jean' })).toBe(true)
|
||||
expect(isCarrierContactNamed({ ...emptyCarrierContact(), lastName: 'Doe' })).toBe(true)
|
||||
// Fonction / téléphone / email seuls ne « nomment » pas (≠ RG-4.08 large).
|
||||
expect(isCarrierContactNamed({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false)
|
||||
expect(isCarrierContactNamed({ ...emptyCarrierContact(), email: 'a@b.fr' })).toBe(false)
|
||||
})
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/carrier_addresses/88', {}, { toast: false })
|
||||
expect(form.addresses.value).toHaveLength(1)
|
||||
it('buildCarrierContactPayload : phones = 1 numéro sans secondaire', () => {
|
||||
const body = buildCarrierContactPayload({ ...emptyCarrierContact(), phonePrimary: '0102030405' })
|
||||
expect(body.phones).toEqual(['0102030405'])
|
||||
})
|
||||
|
||||
it('buildCarrierContactPayload : phones = 2 numéros si secondaire révélé', () => {
|
||||
const body = buildCarrierContactPayload({
|
||||
...emptyCarrierContact(),
|
||||
phonePrimary: '0102030405',
|
||||
phoneSecondary: '0605040302',
|
||||
hasSecondaryPhone: true,
|
||||
})
|
||||
expect(body.phones).toEqual(['0102030405', '0605040302'])
|
||||
})
|
||||
|
||||
it('buildCarrierContactPayload : 2e numéro ignoré tant que non révélé', () => {
|
||||
const body = buildCarrierContactPayload({
|
||||
...emptyCarrierContact(),
|
||||
phonePrimary: '0102030405',
|
||||
phoneSecondary: '0605040302',
|
||||
hasSecondaryPhone: false,
|
||||
})
|
||||
expect(body.phones).toEqual(['0102030405'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCarrierForm — onglet Contacts (ERP-168)', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
mockDelete.mockReset()
|
||||
})
|
||||
|
||||
/** Transporteur créé, onglet Contacts accessible. */
|
||||
function createdForm() {
|
||||
const form = useCarrierForm()
|
||||
form.carrierId.value = 7
|
||||
return form
|
||||
}
|
||||
|
||||
it('« + Nouveau contact » désactivé tant que le bloc n\'est pas nommé (prénom OU nom, aligné M1/M2/M3)', () => {
|
||||
const form = createdForm()
|
||||
expect(form.canAddContact.value).toBe(false)
|
||||
|
||||
// addContact est un no-op tant que le bloc n'est pas nommé.
|
||||
form.addContact()
|
||||
expect(form.contacts.value).toHaveLength(1)
|
||||
|
||||
// Fonction seule ne suffit PAS à ajouter un nouveau bloc (≠ RG-4.08 large).
|
||||
const first = form.contacts.value[0]
|
||||
if (first) first.jobTitle = 'Acheteur'
|
||||
expect(form.canAddContact.value).toBe(false)
|
||||
form.addContact()
|
||||
expect(form.contacts.value).toHaveLength(1)
|
||||
|
||||
// Un nom (ou prénom) débloque l'ajout.
|
||||
if (first) first.lastName = 'Doe'
|
||||
expect(form.canAddContact.value).toBe(true)
|
||||
form.addContact()
|
||||
expect(form.contacts.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('submitContacts : POST des nouveaux contacts (phones tableau), capture id, finalise', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 55 })
|
||||
const form = createdForm()
|
||||
const c = form.contacts.value[0]
|
||||
if (c) { c.firstName = 'Jean'; c.phonePrimary = '0102030405' }
|
||||
|
||||
const ok = await form.submitContacts(vi.fn())
|
||||
|
||||
expect(ok).toBe(true)
|
||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||
expect(url).toBe('/carriers/7/contacts')
|
||||
expect(body).toMatchObject({ firstName: 'Jean', phones: ['0102030405'] })
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
expect(form.contacts.value[0]?.id).toBe(55)
|
||||
expect(form.isValidated('contacts')).toBe(true)
|
||||
})
|
||||
|
||||
it('submitContacts : PATCH des contacts existants sur /carrier_contacts/{id}', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
const c = form.contacts.value[0]
|
||||
if (c) { c.id = 55; c.lastName = 'Doe' }
|
||||
|
||||
await form.submitContacts(vi.fn())
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith('/carrier_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false })
|
||||
})
|
||||
|
||||
it('RG-4.08 : onglet vide → soumet l\'amorce pour déclencher la 422 inline', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
const form = createdForm()
|
||||
|
||||
const ok = await form.submitContacts(vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||
expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.')
|
||||
expect(form.isValidated('contacts')).toBe(false)
|
||||
})
|
||||
|
||||
it('removeContact : DELETE /carrier_contacts/{id} puis retrait du bloc', async () => {
|
||||
mockDelete.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
const c = form.contacts.value[0]
|
||||
if (c) { c.id = 90; c.lastName = 'Doe' }
|
||||
form.addContact()
|
||||
const c2 = form.contacts.value[1]
|
||||
if (c2) c2.firstName = 'Jean'
|
||||
|
||||
await form.removeContact(0)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/carrier_contacts/90', {}, { toast: false })
|
||||
expect(form.contacts.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('carrierPrice (util) — bascule CLIENT/FOURNISSEUR + champs requis par branche', () => {
|
||||
const CLIENT = '/api/clients/3'
|
||||
const CLIENT_ADDR = '/api/client_addresses/8'
|
||||
const SUPPLIER = '/api/suppliers/5'
|
||||
const SUPPLIER_ADDR = '/api/supplier_addresses/9'
|
||||
const SITE = '/api/sites/1'
|
||||
|
||||
it('buildCarrierPricePayload CLIENT : branche client envoyée, branche fournisseur à null', () => {
|
||||
const body = buildCarrierPricePayload({
|
||||
...emptyCarrierPrice(),
|
||||
direction: 'CLIENT',
|
||||
clientIri: CLIENT,
|
||||
clientDeliveryAddressIri: CLIENT_ADDR,
|
||||
departureSiteIri: SITE,
|
||||
containerType: 'BENNE',
|
||||
pricingUnit: 'FORFAIT',
|
||||
price: '120.00',
|
||||
priceState: 'EN_COURS',
|
||||
})
|
||||
expect(body).toMatchObject({
|
||||
direction: 'CLIENT',
|
||||
client: CLIENT,
|
||||
clientDeliveryAddress: CLIENT_ADDR,
|
||||
departureSite: SITE,
|
||||
supplier: null,
|
||||
supplierSupplyAddress: null,
|
||||
deliverySite: null,
|
||||
containerType: 'BENNE',
|
||||
pricingUnit: 'FORFAIT',
|
||||
price: '120.00',
|
||||
priceState: 'EN_COURS',
|
||||
})
|
||||
})
|
||||
|
||||
it('buildCarrierPricePayload FOURNISSEUR : branche fournisseur envoyée, branche client à null', () => {
|
||||
const body = buildCarrierPricePayload({
|
||||
...emptyCarrierPrice(),
|
||||
direction: 'FOURNISSEUR',
|
||||
supplierIri: SUPPLIER,
|
||||
supplierSupplyAddressIri: SUPPLIER_ADDR,
|
||||
deliverySiteIri: SITE,
|
||||
containerType: 'FOND_MOUVANT',
|
||||
pricingUnit: 'TONNE',
|
||||
price: '45',
|
||||
priceState: 'VALIDE',
|
||||
})
|
||||
expect(body).toMatchObject({
|
||||
direction: 'FOURNISSEUR',
|
||||
supplier: SUPPLIER,
|
||||
supplierSupplyAddress: SUPPLIER_ADDR,
|
||||
deliverySite: SITE,
|
||||
client: null,
|
||||
clientDeliveryAddress: null,
|
||||
departureSite: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('isCarrierPriceValid : faux si branche incomplète, vrai si branche complète + communs', () => {
|
||||
const base = { ...emptyCarrierPrice(), containerType: 'BENNE', pricingUnit: 'FORFAIT', price: '10', priceState: 'EN_COURS' }
|
||||
// Direction non choisie → invalide.
|
||||
expect(isCarrierPriceValid({ ...base, direction: null })).toBe(false)
|
||||
// Sens CLIENT par défaut mais branche incomplète → invalide.
|
||||
expect(isCarrierPriceValid(base)).toBe(false)
|
||||
// CLIENT sans adresse/site → invalide.
|
||||
expect(isCarrierPriceValid({ ...base, direction: 'CLIENT', clientIri: CLIENT })).toBe(false)
|
||||
// CLIENT complet → valide.
|
||||
expect(isCarrierPriceValid({ ...base, direction: 'CLIENT', clientIri: CLIENT, clientDeliveryAddressIri: CLIENT_ADDR, departureSiteIri: SITE })).toBe(true)
|
||||
// FOURNISSEUR complet → valide.
|
||||
expect(isCarrierPriceValid({ ...base, direction: 'FOURNISSEUR', supplierIri: SUPPLIER, supplierSupplyAddressIri: SUPPLIER_ADDR, deliverySiteIri: SITE })).toBe(true)
|
||||
// Prix manquant → invalide même branche complète.
|
||||
expect(isCarrierPriceValid({ ...emptyCarrierPrice(), direction: 'CLIENT', clientIri: CLIENT, clientDeliveryAddressIri: CLIENT_ADDR, departureSiteIri: SITE, containerType: 'BENNE', pricingUnit: 'FORFAIT', priceState: 'EN_COURS' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCarrierForm — onglet Prix (ERP-169)', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
mockDelete.mockReset()
|
||||
})
|
||||
|
||||
function createdForm() {
|
||||
const form = useCarrierForm()
|
||||
form.carrierId.value = 7
|
||||
return form
|
||||
}
|
||||
|
||||
it('démarre avec un bloc CLIENT par défaut ; « + Nouveau prix » bloqué tant qu\'il est incomplet', () => {
|
||||
const form = createdForm()
|
||||
// Un bloc présent d'office, sens CLIENT pré-sélectionné.
|
||||
expect(form.prices.value).toHaveLength(1)
|
||||
expect(form.prices.value[0]?.direction).toBe('CLIENT')
|
||||
// Bloc incomplet → on ne peut pas en ajouter un autre.
|
||||
expect(form.canAddPrice.value).toBe(false)
|
||||
form.addPrice()
|
||||
expect(form.prices.value).toHaveLength(1)
|
||||
|
||||
// Une fois le bloc complété, l'ajout est autorisé.
|
||||
const p = form.prices.value[0]
|
||||
if (p) {
|
||||
p.clientIri = '/api/clients/3'
|
||||
p.clientDeliveryAddressIri = '/api/client_addresses/8'
|
||||
p.departureSiteIri = '/api/sites/1'
|
||||
p.price = '120'
|
||||
p.priceState = 'EN_COURS'
|
||||
}
|
||||
expect(form.canAddPrice.value).toBe(true)
|
||||
form.addPrice()
|
||||
expect(form.prices.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('submitPrices : POST des nouveaux prix (branche CLIENT), capture l\'id, finalise', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 50 })
|
||||
const form = createdForm()
|
||||
form.addPrice()
|
||||
const p = form.prices.value[0]
|
||||
if (p) {
|
||||
p.direction = 'CLIENT'
|
||||
p.clientIri = '/api/clients/3'
|
||||
p.clientDeliveryAddressIri = '/api/client_addresses/8'
|
||||
p.departureSiteIri = '/api/sites/1'
|
||||
p.containerType = 'BENNE'
|
||||
p.pricingUnit = 'FORFAIT'
|
||||
p.price = '120'
|
||||
p.priceState = 'EN_COURS'
|
||||
}
|
||||
|
||||
const ok = await form.submitPrices(vi.fn())
|
||||
|
||||
expect(ok).toBe(true)
|
||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||
expect(url).toBe('/carriers/7/prices')
|
||||
expect(body).toMatchObject({ direction: 'CLIENT', client: '/api/clients/3', supplier: null })
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
expect(form.prices.value[0]?.id).toBe(50)
|
||||
expect(form.isValidated('prices')).toBe(true)
|
||||
})
|
||||
|
||||
it('submitPrices : PATCH des prix existants sur /carrier_prices/{id}', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
const p = form.prices.value[0]
|
||||
if (p) {
|
||||
p.id = 50
|
||||
p.direction = 'FOURNISSEUR'
|
||||
p.supplierIri = '/api/suppliers/5'
|
||||
p.supplierSupplyAddressIri = '/api/supplier_addresses/9'
|
||||
p.deliverySiteIri = '/api/sites/1'
|
||||
p.price = '10'
|
||||
p.priceState = 'VALIDE'
|
||||
}
|
||||
|
||||
await form.submitPrices(vi.fn())
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith('/carrier_prices/50', expect.objectContaining({ direction: 'FOURNISSEUR' }), { toast: false })
|
||||
})
|
||||
|
||||
it('front : bloc prix incomplet → erreurs inline sous chaque champ requis, pas d\'appel back', async () => {
|
||||
const form = createdForm()
|
||||
// Bloc CLIENT par défaut, rien d'autre rempli.
|
||||
const ok = await form.submitPrices(vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
const errs = form.priceErrors.value[0]
|
||||
expect(errs?.client).toBeTruthy()
|
||||
expect(errs?.clientDeliveryAddress).toBeTruthy()
|
||||
expect(errs?.departureSite).toBeTruthy()
|
||||
expect(errs?.price).toBeTruthy()
|
||||
expect(errs?.priceState).toBeTruthy()
|
||||
})
|
||||
|
||||
it('submitPrices : mappe les 422 back par ligne (appartenance adresse) et ne finalise pas', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: { status: 422, _data: { violations: [{ propertyPath: 'clientDeliveryAddress', message: 'L\'adresse de livraison doit appartenir au client selectionne.' }] } },
|
||||
})
|
||||
const form = createdForm()
|
||||
// Tous les champs requis remplis (le pré-check front passe) ; le back 422 sur
|
||||
// une RG qu'il est seul à connaître (appartenance de l'adresse au client).
|
||||
const p = form.prices.value[0]
|
||||
if (p) {
|
||||
p.direction = 'CLIENT'
|
||||
p.clientIri = '/api/clients/3'
|
||||
p.clientDeliveryAddressIri = '/api/client_addresses/8'
|
||||
p.departureSiteIri = '/api/sites/1'
|
||||
p.price = '10'
|
||||
p.priceState = 'EN_COURS'
|
||||
}
|
||||
|
||||
const ok = await form.submitPrices(vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.priceErrors.value[0]?.clientDeliveryAddress).toBe('L\'adresse de livraison doit appartenir au client selectionne.')
|
||||
expect(form.isValidated('prices')).toBe(false)
|
||||
})
|
||||
|
||||
it('removePrice : DELETE /carrier_prices/{id} puis retrait du bloc', async () => {
|
||||
mockDelete.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
form.addPrice()
|
||||
const p = form.prices.value[0]
|
||||
if (p) { p.id = 77; p.direction = 'CLIENT'; p.clientIri = '/api/clients/3'; p.clientDeliveryAddressIri = '/api/client_addresses/8'; p.departureSiteIri = '/api/sites/1'; p.containerType = 'BENNE'; p.pricingUnit = 'FORFAIT'; p.price = '10'; p.priceState = 'EN_COURS' }
|
||||
form.addPrice()
|
||||
|
||||
await form.removePrice(0)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/carrier_prices/77', {}, { toast: false })
|
||||
expect(form.prices.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCarrierForm — édition (ERP-170)', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
})
|
||||
|
||||
it('prefillFrom : peuple carrierId + principal + sous-collections, passe en editMode', () => {
|
||||
const form = useCarrierForm()
|
||||
form.prefillFrom({
|
||||
'@id': '/api/carriers/7',
|
||||
id: 7,
|
||||
name: 'TRANSPORTS ACME',
|
||||
certificationType: 'GMP_PLUS',
|
||||
address: { '@id': '/api/carrier_addresses/3', id: 3, city: 'Poitiers' },
|
||||
contacts: [{ '@id': '/api/carrier_contacts/9', id: 9, lastName: 'Doe', phonePrimary: '0102030405' }],
|
||||
prices: [{ '@id': '/api/carrier_prices/5', id: 5, direction: 'CLIENT', client: { '@id': '/api/clients/3' }, containerType: 'BENNE', pricingUnit: 'FORFAIT', price: '120', priceState: 'EN_COURS' }],
|
||||
})
|
||||
|
||||
expect(form.carrierId.value).toBe(7)
|
||||
expect(form.editMode.value).toBe(true)
|
||||
expect(form.main.name).toBe('TRANSPORTS ACME')
|
||||
expect(form.main.certificationType).toBe('GMP_PLUS')
|
||||
expect(form.address.value.id).toBe(3)
|
||||
expect(form.contacts.value[0]?.id).toBe(9)
|
||||
expect(form.prices.value[0]?.clientIri).toBe('/api/clients/3')
|
||||
})
|
||||
|
||||
it('updateMain : PATCH /carriers/{id} (pas de POST), réaffiche le nom normalisé', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ id: 7, name: 'TRANSPORTS ACME', certificationType: 'GMP_PLUS' })
|
||||
const form = useCarrierForm()
|
||||
form.prefillFrom({ '@id': '/api/carriers/7', id: 7, name: 'Transports Acme', certificationType: 'GMP_PLUS' })
|
||||
|
||||
const ok = await form.updateMain()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/carriers/7',
|
||||
expect.objectContaining({ name: 'Transports Acme', certificationType: 'GMP_PLUS' }),
|
||||
{ toast: false },
|
||||
)
|
||||
expect(form.main.name).toBe('TRANSPORTS ACME')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCarrierForm — modification : Qualimat + certification (ERP-172)', () => {
|
||||
const QUALIMAT_ROW = {
|
||||
'@id': '/api/qualimat_carriers/42',
|
||||
id: '42',
|
||||
name: 'TRANSPORTS QUALIMAT',
|
||||
address: '1 rue du Port',
|
||||
postalCode: '86000',
|
||||
city: 'Poitiers',
|
||||
validityDate: '2027-01-15',
|
||||
status: 'VALIDE',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
})
|
||||
|
||||
it('setCertification : quitter QUALIMAT délie la FK qualimatCarrier', () => {
|
||||
const form = useCarrierForm()
|
||||
form.main.qualimatCarrierIri = '/api/qualimat_carriers/42'
|
||||
form.main.certificationType = 'QUALIMAT'
|
||||
|
||||
form.setCertification('GMP_PLUS')
|
||||
|
||||
expect(form.main.certificationType).toBe('GMP_PLUS')
|
||||
expect(form.main.qualimatCarrierIri).toBeNull()
|
||||
})
|
||||
|
||||
it('certificationReadonly : éditable en modification même pour un QUALIMAT', () => {
|
||||
const form = useCarrierForm()
|
||||
form.prefillFrom({
|
||||
'@id': '/api/carriers/7', id: 7, name: 'ACME', certificationType: 'QUALIMAT',
|
||||
qualimatCarrier: { '@id': '/api/qualimat_carriers/42' },
|
||||
})
|
||||
expect(form.isQualimat.value).toBe(true)
|
||||
expect(form.certificationReadonly.value).toBe(false)
|
||||
})
|
||||
|
||||
it('buildMainPayload : en modification, délie le Qualimat (qualimatCarrier: null) sans lien', () => {
|
||||
const form = useCarrierForm()
|
||||
form.prefillFrom({
|
||||
'@id': '/api/carriers/7', id: 7, name: 'ACME', certificationType: 'QUALIMAT',
|
||||
qualimatCarrier: { '@id': '/api/qualimat_carriers/42' },
|
||||
})
|
||||
form.setCertification('GMP_PLUS')
|
||||
|
||||
expect(form.buildMainPayload()).toMatchObject({ certificationType: 'GMP_PLUS', qualimatCarrier: null })
|
||||
})
|
||||
|
||||
it('applyQualimatSelection : en modification, conserve l\'adresse existante (PATCH nom/certif/FK)', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = useCarrierForm()
|
||||
form.prefillFrom({
|
||||
'@id': '/api/carriers/7', id: 7, name: 'OLD', certificationType: 'GMP_PLUS',
|
||||
address: { '@id': '/api/carrier_addresses/3', id: 3, city: 'Poitiers', street: 'rue A' },
|
||||
})
|
||||
const addressBefore = { ...form.address.value }
|
||||
|
||||
const ok = await form.applyQualimatSelection(QUALIMAT_ROW)
|
||||
|
||||
expect(ok).toBe(true)
|
||||
// Décision « conserver » (ERP-172) : l'adresse n'est pas réécrite en modification.
|
||||
expect(form.address.value).toEqual(addressBefore)
|
||||
// Nom + certification + FK actualisés via PATCH.
|
||||
expect(form.main.name).toBe('TRANSPORTS QUALIMAT')
|
||||
expect(form.main.certificationType).toBe('QUALIMAT')
|
||||
expect(form.main.qualimatCarrierIri).toBe('/api/qualimat_carriers/42')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ref } from 'vue'
|
||||
import type { CarrierDetail } from '~/modules/transport/utils/forms/carrierMappers'
|
||||
|
||||
/**
|
||||
* Chargement et actions d'archivage d'un transporteur unique (écrans Consultation /
|
||||
* Modification, ERP-170). Miroir de `useProvider` (M3) / `useSupplier` (M2). Lit le
|
||||
* détail embarqué via `GET /api/carriers/{id}` (qualimatCarrier + addresses /
|
||||
* contacts / prices sous `carrier:item:read`, relations cross-module via leurs
|
||||
* read-groups) — une SEULE requête peuple les deux écrans (embed borné, pas de N+1).
|
||||
*
|
||||
* L'en-tête `Accept: application/ld+json` est imposé pour obtenir le payload Hydra
|
||||
* complet (avec les `@id` des relations embarquées, indispensables au préremplissage).
|
||||
*
|
||||
* État 100 % local à l'instance (refs). Les erreurs d'archivage / restauration
|
||||
* (notamment le 409 d'homonyme actif à la restauration) sont PROPAGÉES à l'appelant.
|
||||
*/
|
||||
export function useCarrier(id: number | string) {
|
||||
const api = useApi()
|
||||
|
||||
const carrier = ref<CarrierDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
|
||||
/** Récupère le détail complet (embed qualimatCarrier + addresses / contacts / prices). */
|
||||
function fetchDetail(): Promise<CarrierDetail> {
|
||||
return api.get<CarrierDetail>(
|
||||
`/carriers/${id}`,
|
||||
{},
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
}
|
||||
|
||||
/** Charge le détail du transporteur. En cas d'échec : `error = true`, `carrier = null`. */
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
carrier.value = await fetchDetail()
|
||||
}
|
||||
catch {
|
||||
error.value = true
|
||||
carrier.value = null
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bascule l'archivage (PATCH `isArchived` SEUL — groupe carrier:write:archive ;
|
||||
* tout autre champ → 422, security archive = Admin seul), puis RECHARGE le détail
|
||||
* complet (la réponse du PATCH ne porte pas l'embed des sous-collections). Toute
|
||||
* erreur est propagée à l'appelant AVANT le rechargement.
|
||||
*/
|
||||
async function setArchived(isArchived: boolean): Promise<void> {
|
||||
await api.patch(`/carriers/${id}`, { isArchived }, { toast: false })
|
||||
carrier.value = await fetchDetail()
|
||||
}
|
||||
|
||||
return {
|
||||
carrier,
|
||||
loading,
|
||||
error,
|
||||
load,
|
||||
archive: () => setArchived(true),
|
||||
restore: () => setArchived(false),
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,31 @@
|
||||
import { computed, reactive, ref, type Ref } from 'vue'
|
||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||
import { useUpload } from '~/shared/composables/useUpload'
|
||||
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
||||
import { removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||
import {
|
||||
emptyCarrierAddress,
|
||||
emptyCarrierAddressCopy,
|
||||
emptyCarrierContact,
|
||||
emptyCarrierMain,
|
||||
emptyCarrierPrice,
|
||||
type CarrierAddressCopy,
|
||||
type CarrierAddressFormDraft,
|
||||
type CarrierContactFormDraft,
|
||||
type CarrierMainDraft,
|
||||
type CarrierMainResponse,
|
||||
type CarrierPriceFormDraft,
|
||||
} from '~/modules/transport/types/carrierForm'
|
||||
import { buildCarrierAddressPayload, isCarrierAddressValid } from '~/modules/transport/utils/forms/carrierAddress'
|
||||
import { buildCarrierAddressPayload } from '~/modules/transport/utils/forms/carrierAddress'
|
||||
import { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } from '~/modules/transport/utils/forms/carrierContact'
|
||||
import { buildCarrierPricePayload, isCarrierPriceValid } from '~/modules/transport/utils/forms/carrierPrice'
|
||||
import {
|
||||
mapAddressToDraft,
|
||||
mapContactToDraft,
|
||||
mapMainToDraft,
|
||||
mapPriceToDraft,
|
||||
type CarrierDetail,
|
||||
} from '~/modules/transport/utils/forms/carrierMappers'
|
||||
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||
|
||||
/** Nom du cas spécial « compte-propre » LIOT (comparaison insensible à la casse, RG-4.01). */
|
||||
@@ -53,6 +67,11 @@ export function useCarrierForm() {
|
||||
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
||||
const mainErrors = useFormErrors()
|
||||
|
||||
// Upload de la décharge (RG-4.02) — infra partagée /api/uploaded_documents.
|
||||
// L'upload est DIFFÉRÉ : le fichier choisi attend ici jusqu'à l'enregistrement.
|
||||
const { uploading: dischargeUploading, upload: uploadFile } = useUpload()
|
||||
const pendingDischargeFile = ref<File | null>(null)
|
||||
|
||||
// ── État du transporteur créé ─────────────────────────────────────────────
|
||||
const carrierId = ref<number | null>(null)
|
||||
const mainLocked = ref(false)
|
||||
@@ -72,8 +91,10 @@ export function useCarrierForm() {
|
||||
// Transporteur QUALIMAT : la FK est posée → certification figée à « QUALIMAT ».
|
||||
const isQualimat = computed(() => main.qualimatCarrierIri !== null)
|
||||
// Certification masquée en cas LIOT ; lecture seule si QUALIMAT (ou bloc verrouillé).
|
||||
// En MODIFICATION (ERP-172) : éditable même pour un QUALIMAT (le métier doit pouvoir
|
||||
// changer la certification) — la sortie de QUALIMAT délie le référentiel.
|
||||
const showCertification = computed(() => !isLiot.value)
|
||||
const certificationReadonly = computed(() => isQualimat.value || mainLocked.value)
|
||||
const certificationReadonly = computed(() => (isQualimat.value && !editMode.value) || mainLocked.value)
|
||||
// RG-4.03 : champs d'affrètement (indexation / contenant / volume) visibles et
|
||||
// obligatoires si « Affréter » coché — masqués en cas LIOT.
|
||||
const showCharteredFields = computed(() => main.isChartered && !isLiot.value)
|
||||
@@ -127,8 +148,9 @@ export function useCarrierForm() {
|
||||
valid = false
|
||||
}
|
||||
|
||||
// RG-4.02 : décharge obligatoire si certification AUTRE.
|
||||
if (main.certificationType === 'AUTRE' && !main.dischargeDocumentIri) {
|
||||
// RG-4.02 : décharge obligatoire si certification AUTRE — satisfaite par un
|
||||
// IRI déjà posé OU un fichier en attente d'upload (différé à l'enregistrement).
|
||||
if (main.certificationType === 'AUTRE' && !main.dischargeDocumentIri && !pendingDischargeFile.value) {
|
||||
mainErrors.setError('dischargeDocument', t('transport.carriers.form.errors.dischargeRequired'))
|
||||
valid = false
|
||||
}
|
||||
@@ -152,6 +174,58 @@ export function useCarrierForm() {
|
||||
return valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Sélection de la décharge (RG-4.02) via `@file-selected` : le fichier est mis
|
||||
* EN ATTENTE, l'upload réel est DIFFÉRÉ à l'enregistrement (`submitMain` /
|
||||
* `updateMain`). Évite les binaires orphelins si l'utilisateur abandonne le
|
||||
* formulaire après avoir choisi un fichier.
|
||||
*/
|
||||
function selectDischarge(file: File): void {
|
||||
mainErrors.clearError('dischargeDocument')
|
||||
pendingDischargeFile.value = file
|
||||
}
|
||||
|
||||
/** Annulation du choix de décharge : oublie le fichier en attente et l'IRI. */
|
||||
function clearDischarge(): void {
|
||||
pendingDischargeFile.value = null
|
||||
main.dischargeDocumentIri = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout l'upload différé au moment de l'enregistrement : s'il y a un fichier
|
||||
* en attente, l'envoie (POST /uploaded_documents) et pose l'IRI sur le
|
||||
* brouillon. Retourne false au 422 (MIME / taille → message inline) pour
|
||||
* interrompre la sauvegarde du transporteur. Pas de fichier en attente → no-op.
|
||||
*/
|
||||
async function resolveDischargeUpload(): Promise<boolean> {
|
||||
if (!pendingDischargeFile.value) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
main.dischargeDocumentIri = await uploadFile(pendingDischargeFile.value)
|
||||
pendingDischargeFile.value = null
|
||||
return true
|
||||
} catch (error) {
|
||||
const message = extractApiErrorMessage((error as { data?: unknown })?.data)
|
||||
|| t('transport.carriers.form.errors.uploadFailed')
|
||||
mainErrors.setError('dischargeDocument', message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change la certification (sélecteur). Quitter « QUALIMAT » délie le référentiel
|
||||
* (FK qualimatCarrier vidée — ERP-172) : un transporteur n'est QUALIMAT que tant
|
||||
* que sa certification l'est. La FK null est propagée au back par buildMainPayload
|
||||
* (en modification uniquement).
|
||||
*/
|
||||
function setCertification(value: string | null): void {
|
||||
main.certificationType = value
|
||||
if (value !== 'QUALIMAT') {
|
||||
main.qualimatCarrierIri = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload du POST principal (groupe `carrier:write:main`). `name` et
|
||||
* `certificationType` sont omis s'ils sont vides afin que la 422 porte la
|
||||
@@ -177,9 +251,14 @@ export function useCarrierForm() {
|
||||
payload.certificationType = main.certificationType
|
||||
}
|
||||
// FK QUALIMAT (saisie assistée, § 2.5) envoyée si une ligne a été intégrée.
|
||||
// En MODIFICATION, on délie explicitement (null) si plus de lien — ex: la
|
||||
// certification a changé de QUALIMAT vers autre chose (ERP-172).
|
||||
if (main.qualimatCarrierIri) {
|
||||
payload.qualimatCarrier = main.qualimatCarrierIri
|
||||
}
|
||||
else if (editMode.value) {
|
||||
payload.qualimatCarrier = null
|
||||
}
|
||||
// RG-4.02 : décharge envoyée seulement en certification AUTRE ; omise quand
|
||||
// absente pour que la 422 « obligatoire » porte sur le champ.
|
||||
if (main.certificationType === 'AUTRE' && main.dischargeDocumentIri) {
|
||||
@@ -214,6 +293,9 @@ export function useCarrierForm() {
|
||||
|
||||
mainSubmitting.value = true
|
||||
try {
|
||||
// Upload différé de la décharge : envoyé seulement maintenant (au Valider).
|
||||
if (!(await resolveDischargeUpload())) return false
|
||||
|
||||
const created = await api.post<CarrierMainResponse>('/carriers', buildMainPayload(), {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
@@ -251,6 +333,71 @@ export function useCarrierForm() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MODIFICATION du formulaire principal (ERP-170) : PATCH /api/carriers/{id} sur le
|
||||
* groupe carrier:write:main (PAS de re-POST). Pré-check front + 409 doublon / 422
|
||||
* inline comme `submitMain`. Ne verrouille rien et ne bascule pas d'onglet (édition
|
||||
* = navigation libre). Retourne true si le PATCH a réussi.
|
||||
*/
|
||||
async function updateMain(): Promise<boolean> {
|
||||
if (carrierId.value === null || mainSubmitting.value) return false
|
||||
mainErrors.clearErrors()
|
||||
if (!validateMainFront()) return false
|
||||
|
||||
mainSubmitting.value = true
|
||||
try {
|
||||
// Upload différé de la décharge : envoyé seulement maintenant (à l'Enregistrer).
|
||||
if (!(await resolveDischargeUpload())) return false
|
||||
|
||||
const updated = await api.patch<CarrierMainResponse>(
|
||||
`/carriers/${carrierId.value}`,
|
||||
buildMainPayload(),
|
||||
{ toast: false },
|
||||
)
|
||||
main.name = updated.name ?? main.name
|
||||
main.certificationType = updated.certificationType ?? main.certificationType
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) {
|
||||
const message = t('transport.carriers.form.duplicateName')
|
||||
mainErrors.setError('name', message)
|
||||
toast.error({ title: t('transport.carriers.toast.error'), message })
|
||||
}
|
||||
else {
|
||||
mainErrors.handleApiError(error, { fallbackMessage: t('transport.carriers.toast.error') })
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
mainSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pré-remplit le formulaire depuis le détail `GET /api/carriers/{id}` (écran
|
||||
* Modification) : peuple carrierId + principal + adresses / contacts / prix via les
|
||||
* mappers, passe en `editMode` (navigation libre, tous onglets accessibles, bloc
|
||||
* principal éditable). Au moins un bloc Adresse / Contact affiché même sans donnée.
|
||||
*/
|
||||
function prefillFrom(detail: CarrierDetail): void {
|
||||
carrierId.value = detail.id
|
||||
editMode.value = true
|
||||
mainLocked.value = false
|
||||
unlockedIndex.value = tabKeys.value.length - 1
|
||||
|
||||
Object.assign(main, mapMainToDraft(detail))
|
||||
|
||||
// Adresse UNIQUE (ERP-172) : objet `address` (ou null) au lieu d'une liste.
|
||||
address.value = detail.address ? mapAddressToDraft(detail.address) : emptyCarrierAddress()
|
||||
|
||||
const mappedContacts = (detail.contacts ?? []).map(mapContactToDraft)
|
||||
contacts.value = mappedContacts.length > 0 ? mappedContacts : [emptyCarrierContact()]
|
||||
|
||||
prices.value = (detail.prices ?? []).map(mapPriceToDraft)
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH partiel du transporteur (mode strict : un seul groupe de sérialisation
|
||||
* par appel — spec-back § 2.9). Servira les onglets à champs scalaires des
|
||||
@@ -308,65 +455,239 @@ export function useCarrierForm() {
|
||||
return hasError
|
||||
}
|
||||
|
||||
// ── Onglet Adresses (ERP-167) ─────────────────────────────────────────────
|
||||
const addresses = ref<CarrierAddressFormDraft[]>([emptyCarrierAddress()])
|
||||
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
|
||||
const addressErrors = ref<Record<string, string>[]>([])
|
||||
// ── Onglet Adresse (ERP-167 / ERP-172 : adresse UNIQUE) ───────────────────
|
||||
// Un transporteur a au plus UNE adresse (décision métier ERP-172) : un seul
|
||||
// bloc, pas d'ajout/suppression. `id` null tant que l'adresse n'est pas créée.
|
||||
const address = ref<CarrierAddressFormDraft>(emptyCarrierAddress())
|
||||
// Erreurs 422 du bloc adresse (mapping inline par champ, ERP-101).
|
||||
const addressErrors = ref<Record<string, string>>({})
|
||||
|
||||
// « + Nouvelle adresse » désactivé tant que la dernière adresse n'est pas
|
||||
// complète (CP + ville + rue — RG-4.05, gate d'ajout).
|
||||
const canAddAddress = computed(() => {
|
||||
const last = addresses.value[addresses.value.length - 1]
|
||||
return last !== undefined && isCarrierAddressValid(last)
|
||||
})
|
||||
|
||||
function addAddress(): void {
|
||||
if (canAddAddress.value) {
|
||||
addresses.value.push(emptyCarrierAddress())
|
||||
/**
|
||||
* Valide l'onglet Adresse : POST sur /carriers/{id}/address (création) ou PATCH
|
||||
* sur /carrier_addresses/{id} (mise à jour), groupe carrier:write:addresses.
|
||||
* Erreurs 422 mappées inline par champ (RG-4.05 « obligatoire si affrété »
|
||||
* re-validée back). Retourne true si l'onglet a été validé.
|
||||
*/
|
||||
async function submitAddress(onError: (error: unknown) => void): Promise<boolean> {
|
||||
if (carrierId.value === null || tabSubmitting.value) {
|
||||
return false
|
||||
}
|
||||
tabSubmitting.value = true
|
||||
addressErrors.value = {}
|
||||
try {
|
||||
const body = buildCarrierAddressPayload(address.value)
|
||||
if (address.value.id === null) {
|
||||
const created = await api.post<{ id: number }>(
|
||||
`/carriers/${carrierId.value}/address`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
address.value.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/carrier_addresses/${address.value.id}`, body, { toast: false })
|
||||
}
|
||||
completeTab('addresses')
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
|
||||
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
|
||||
if (Object.keys(mapped).length > 0) {
|
||||
addressErrors.value = mapped
|
||||
}
|
||||
else {
|
||||
onError(error)
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Suppression immédiate d'une adresse existante (DELETE /carrier_addresses/{id}). */
|
||||
async function removeAddress(index: number): Promise<void> {
|
||||
// ── Onglet Contacts (ERP-168) ─────────────────────────────────────────────
|
||||
const contacts = ref<CarrierContactFormDraft[]>([emptyCarrierContact()])
|
||||
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
|
||||
const contactErrors = ref<Record<string, string>[]>([])
|
||||
|
||||
// « + Nouveau contact » désactivé tant que le DERNIER bloc n'est pas « nommé »
|
||||
// (prénom OU nom) — aligné sur M1/M2/M3 (fonction / téléphone / email seuls ne
|
||||
// suffisent pas à ajouter un nouveau bloc).
|
||||
const canAddContact = computed(() => {
|
||||
const last = contacts.value[contacts.value.length - 1]
|
||||
return last !== undefined && isCarrierContactNamed(last)
|
||||
})
|
||||
|
||||
function addContact(): void {
|
||||
if (canAddContact.value) {
|
||||
contacts.value.push(emptyCarrierContact())
|
||||
}
|
||||
}
|
||||
|
||||
/** Suppression immédiate d'un contact existant (DELETE /carrier_contacts/{id}). */
|
||||
async function removeContact(index: number): Promise<void> {
|
||||
await removeCollectionRow({
|
||||
rows: addresses.value,
|
||||
errors: addressErrors.value,
|
||||
rows: contacts.value,
|
||||
errors: contactErrors.value,
|
||||
index,
|
||||
endpoint: '/carrier_addresses',
|
||||
endpoint: '/carrier_contacts',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyCarrierAddress,
|
||||
makeEmpty: emptyCarrierContact,
|
||||
onError: notifyRemovalError,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Adresses : POST des nouvelles adresses sur
|
||||
* /carriers/{id}/addresses, PATCH des existantes sur /carrier_addresses/{id}
|
||||
* (groupe carrier:write:addresses). Erreurs 422 collectées par ligne (RG-4.05
|
||||
* « obligatoire si affrété » re-validée back). Retourne true si l'onglet a été
|
||||
* validé (avancé/terminé).
|
||||
* Valide l'onglet Contacts : POST des nouveaux contacts sur
|
||||
* /carriers/{id}/contacts, PATCH des existants sur /carrier_contacts/{id}
|
||||
* (groupe carrier:write:contacts). RG-4.08 (≥ 1 champ rempli, max 2 téléphones)
|
||||
* re-validée back → 422 par ligne. Si l'onglet ne contient QUE des amorces
|
||||
* vides, on soumet la 1re pour déclencher la 422 RG-4.08 inline plutôt que de
|
||||
* finaliser un onglet vide. Retourne true si l'onglet a été validé.
|
||||
*/
|
||||
async function submitAddresses(onError: (error: unknown) => void): Promise<boolean> {
|
||||
async function submitContacts(onError: (error: unknown) => void): Promise<boolean> {
|
||||
if (carrierId.value === null || tabSubmitting.value) {
|
||||
return false
|
||||
}
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
const hasSubmittable = contacts.value.some(c => c.id !== null || !isCarrierContactBlank(c))
|
||||
const hasError = await submitRows(
|
||||
addresses.value,
|
||||
addressErrors,
|
||||
async (address) => {
|
||||
const body = buildCarrierAddressPayload(address)
|
||||
if (address.id === null) {
|
||||
contacts.value,
|
||||
contactErrors,
|
||||
async (contact) => {
|
||||
const body = buildCarrierContactPayload(contact)
|
||||
if (contact.id === null) {
|
||||
const created = await api.post<{ id: number }>(
|
||||
`/carriers/${carrierId.value}/addresses`,
|
||||
`/carriers/${carrierId.value}/contacts`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
address.id = created.id
|
||||
contact.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/carrier_addresses/${address.id}`, body, { toast: false })
|
||||
await api.patch(`/carrier_contacts/${contact.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
onError,
|
||||
// Amorce vide neuve ignorée s'il reste un autre bloc soumettable ;
|
||||
// sinon on la soumet pour déclencher la 422 RG-4.08 (sur firstName).
|
||||
contact => hasSubmittable && contact.id === null && isCarrierContactBlank(contact),
|
||||
)
|
||||
if (hasError) {
|
||||
return false
|
||||
}
|
||||
completeTab('contacts')
|
||||
return true
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Onglet Prix (ERP-169) ─────────────────────────────────────────────────
|
||||
// Un bloc présent par défaut (sens CLIENT pré-sélectionné). L'utilisateur ajoute
|
||||
// les suivants via « + Nouveau prix ».
|
||||
const prices = ref<CarrierPriceFormDraft[]>([emptyCarrierPrice()])
|
||||
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
|
||||
const priceErrors = ref<Record<string, string>[]>([])
|
||||
|
||||
// « + Nouveau prix » : autorisé si la liste est vide, sinon le dernier bloc doit
|
||||
// être valide (branche complète + prix — RG-4.09→4.11, pré-check léger).
|
||||
const canAddPrice = computed(() => {
|
||||
const last = prices.value[prices.value.length - 1]
|
||||
return last === undefined || isCarrierPriceValid(last)
|
||||
})
|
||||
|
||||
function addPrice(): void {
|
||||
if (canAddPrice.value) {
|
||||
prices.value.push(emptyCarrierPrice())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pré-validation FRONT d'un bloc prix (ERP-101) : renvoie les erreurs inline par
|
||||
* champ obligatoire (sens + branche active + communs). Nécessaire car côté back
|
||||
* l'Assert\NotBlank sur les scalaires (price/priceState...) court-circuite la
|
||||
* validation de branche du CarrierPriceProcessor : le 422 ne porterait jamais
|
||||
* client/supplier/adresses en même temps. Messages alignés sur le back.
|
||||
*/
|
||||
function validatePriceRow(price: CarrierPriceFormDraft): Record<string, string> {
|
||||
const errs: Record<string, string> = {}
|
||||
const msg = (key: string): string => t(`transport.carriers.form.price.errors.${key}`)
|
||||
|
||||
if (!price.direction) {
|
||||
errs.direction = msg('direction')
|
||||
}
|
||||
if (price.direction === 'CLIENT') {
|
||||
if (!price.clientIri) errs.client = msg('client')
|
||||
if (!price.clientDeliveryAddressIri) errs.clientDeliveryAddress = msg('clientDeliveryAddress')
|
||||
if (!price.departureSiteIri) errs.departureSite = msg('departureSite')
|
||||
}
|
||||
if (price.direction === 'FOURNISSEUR') {
|
||||
if (!price.supplierIri) errs.supplier = msg('supplier')
|
||||
if (!price.supplierSupplyAddressIri) errs.supplierSupplyAddress = msg('supplierSupplyAddress')
|
||||
if (!price.deliverySiteIri) errs.deliverySite = msg('deliverySite')
|
||||
}
|
||||
if (!price.containerType) errs.containerType = msg('containerType')
|
||||
if (!price.pricingUnit) errs.pricingUnit = msg('pricingUnit')
|
||||
if (!price.price || price.price.trim() === '') errs.price = msg('price')
|
||||
if (!price.priceState) errs.priceState = msg('priceState')
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
/** Suppression immédiate d'un prix existant (DELETE /carrier_prices/{id}). */
|
||||
async function removePrice(index: number): Promise<void> {
|
||||
await removeCollectionRow({
|
||||
rows: prices.value,
|
||||
errors: priceErrors.value,
|
||||
index,
|
||||
endpoint: '/carrier_prices',
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyCarrierPrice,
|
||||
onError: notifyRemovalError,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Prix : POST des nouveaux prix sur /carriers/{id}/prices, PATCH
|
||||
* des existants sur /carrier_prices/{id} (groupe carrier:write:prices). La
|
||||
* cohérence de branche CLIENT/FOURNISSEUR (RG-4.09→4.11) est re-validée back →
|
||||
* 422 par ligne. Onglet Prix optionnel : une liste vide finalise sans appel.
|
||||
* Retourne true si l'onglet a été validé (création terminée).
|
||||
*/
|
||||
async function submitPrices(onError: (error: unknown) => void): Promise<boolean> {
|
||||
if (carrierId.value === null || tabSubmitting.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Pré-check front : affiche toutes les obligations sous leur champ d'un coup
|
||||
// (le back ne peut pas tout renvoyer en une passe — cf. validatePriceRow).
|
||||
const frontErrors = prices.value.map(validatePriceRow)
|
||||
if (frontErrors.some(errs => Object.keys(errs).length > 0)) {
|
||||
priceErrors.value = frontErrors
|
||||
return false
|
||||
}
|
||||
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
const hasError = await submitRows(
|
||||
prices.value,
|
||||
priceErrors,
|
||||
async (price) => {
|
||||
const body = buildCarrierPricePayload(price)
|
||||
if (price.id === null) {
|
||||
const created = await api.post<{ id: number }>(
|
||||
`/carriers/${carrierId.value}/prices`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
price.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/carrier_prices/${price.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
onError,
|
||||
@@ -374,7 +695,7 @@ export function useCarrierForm() {
|
||||
if (hasError) {
|
||||
return false
|
||||
}
|
||||
completeTab('addresses')
|
||||
completeTab('prices')
|
||||
return true
|
||||
}
|
||||
finally {
|
||||
@@ -417,16 +738,20 @@ export function useCarrierForm() {
|
||||
city: row.city ?? '',
|
||||
street: row.address ?? '',
|
||||
}
|
||||
// RG-4.05 : pré-remplit le 1er bloc de l'onglet Adresses par copie (la FK
|
||||
// QUALIMAT survit, les champs restent éditables — § 2.5).
|
||||
addresses.value = [{
|
||||
id: null,
|
||||
country: 'France',
|
||||
postalCode: row.postalCode || null,
|
||||
city: row.city || null,
|
||||
street: row.address || null,
|
||||
streetComplement: null,
|
||||
}]
|
||||
// RG-4.05 : à la CRÉATION, pré-remplit l'adresse (unique) par copie du
|
||||
// référentiel QUALIMAT (champs éditables, la FK QUALIMAT survit — § 2.5).
|
||||
// En MODIFICATION (ERP-172) : on NE TOUCHE PAS l'adresse déjà saisie — la
|
||||
// re-sélection Qualimat actualise seulement nom + certification + FK.
|
||||
if (!editMode.value) {
|
||||
address.value = {
|
||||
id: null,
|
||||
country: 'France',
|
||||
postalCode: row.postalCode || null,
|
||||
city: row.city || null,
|
||||
street: row.address || null,
|
||||
streetComplement: null,
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -460,6 +785,7 @@ export function useCarrierForm() {
|
||||
mainSubmitting,
|
||||
tabSubmitting,
|
||||
mainErrors,
|
||||
dischargeUploading,
|
||||
// affichage conditionnel
|
||||
isLiot,
|
||||
isQualimat,
|
||||
@@ -474,17 +800,33 @@ export function useCarrierForm() {
|
||||
validated,
|
||||
editMode,
|
||||
isValidated,
|
||||
// adresses
|
||||
addresses,
|
||||
// adresse (unique)
|
||||
address,
|
||||
addressErrors,
|
||||
canAddAddress,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
submitAddresses,
|
||||
submitAddress,
|
||||
// contacts
|
||||
contacts,
|
||||
contactErrors,
|
||||
canAddContact,
|
||||
addContact,
|
||||
removeContact,
|
||||
submitContacts,
|
||||
// prix
|
||||
prices,
|
||||
priceErrors,
|
||||
canAddPrice,
|
||||
addPrice,
|
||||
removePrice,
|
||||
submitPrices,
|
||||
// actions
|
||||
setCertification,
|
||||
selectDischarge,
|
||||
clearDischarge,
|
||||
validateMainFront,
|
||||
buildMainPayload,
|
||||
submitMain,
|
||||
updateMain,
|
||||
prefillFrom,
|
||||
patchCarrier,
|
||||
applyQualimatSelection,
|
||||
completeTab,
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tête : retour consultation + titre. -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
v-bind="{ ariaLabel: t('transport.carriers.edit.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('transport.carriers.edit.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('transport.carriers.edit.loading') }}</p>
|
||||
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('transport.carriers.edit.notFound') }}</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- ── Formulaire principal (éditable, PATCH partiel) ─────────────── -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="main.name"
|
||||
:label="t('transport.carriers.form.main.name')"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.name"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isLiot"
|
||||
v-model="main.liotPlates"
|
||||
:label="t('transport.carriers.form.main.liotPlates')"
|
||||
:hint="t('transport.carriers.form.main.liotPlatesHint')"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.liotPlates"
|
||||
/>
|
||||
<template v-if="!isLiot">
|
||||
<MalioSelect
|
||||
:model-value="main.certificationType"
|
||||
:options="certificationOptions"
|
||||
:label="t('transport.carriers.form.main.certificationType')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="certificationReadonly"
|
||||
:error="mainErrors.errors.certificationType"
|
||||
@update:model-value="(v: string | number | null) => setCertification(v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioInputUpload
|
||||
v-if="showDischarge"
|
||||
:model-value="dischargeFileName"
|
||||
:label="t('transport.carriers.form.main.discharge')"
|
||||
accept="application/pdf,image/*"
|
||||
:required="true"
|
||||
:readonly="dischargeUploading"
|
||||
:clearable="true"
|
||||
:error="mainErrors.errors.dischargeDocument"
|
||||
@update:model-value="(v: string) => dischargeFileName = v"
|
||||
@file-selected="selectDischarge"
|
||||
@clear="onClearDischarge"
|
||||
/>
|
||||
<div v-else class="hidden xl:block"></div>
|
||||
<div class="flex h-12 items-center">
|
||||
<MalioCheckbox
|
||||
id="carrier-edit-chartered"
|
||||
:label="t('transport.carriers.form.main.isChartered')"
|
||||
:model-value="main.isChartered"
|
||||
:reserve-message-space="false"
|
||||
@update:model-value="(val: boolean) => main.isChartered = val"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="showCharteredFields">
|
||||
<MalioInputAmount
|
||||
:key="indexationKey"
|
||||
:model-value="main.indexationRate"
|
||||
:label="t('transport.carriers.form.main.indexationRate')"
|
||||
icon-name="mdi:percent"
|
||||
icon-position="right"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.indexationRate"
|
||||
@update:model-value="onIndexationInput"
|
||||
/>
|
||||
<!-- Contenant : Benne / Fond mouvant en radios, centrés (h-12) comme
|
||||
à l'onglet Prix (Benne par défaut). -->
|
||||
<div>
|
||||
<div class="flex h-12 items-center gap-4">
|
||||
<MalioRadioButton
|
||||
:model-value="main.containerType"
|
||||
name="carrier-main-container"
|
||||
value="BENNE"
|
||||
:label="t('transport.carriers.containerType.BENNE')"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioRadioButton
|
||||
:model-value="main.containerType"
|
||||
name="carrier-main-container"
|
||||
value="FOND_MOUVANT"
|
||||
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="mainErrors.errors.containerType" class="ml-[2px] text-xs text-m-danger">{{ mainErrors.errors.containerType }}</p>
|
||||
</div>
|
||||
<MalioInputText
|
||||
:model-value="main.volumeM3"
|
||||
:label="t('transport.carriers.form.main.volumeM3')"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.volumeM3"
|
||||
@update:model-value="(v: string) => main.volumeM3 = sanitizeDecimal(v)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('transport.carriers.edit.save')"
|
||||
:disabled="mainSubmitting"
|
||||
@click="onUpdateMain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets éditables (navigation libre, PATCH partiel par onglet) ── -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||
<!-- Qualimat : actualiser nom + certification depuis le référentiel (ERP-172). -->
|
||||
<template #qualimat>
|
||||
<CarrierQualimatTab
|
||||
:search-name="main.name"
|
||||
:selected-iri="main.qualimatCarrierIri"
|
||||
@integrate="onIntegrateQualimat"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #addresses>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
|
||||
<CarrierAddressBlock
|
||||
:model-value="address"
|
||||
:country-options="countryOptions"
|
||||
:errors="addressErrors"
|
||||
@update:model-value="(v) => address = v"
|
||||
@degraded="onAddressDegraded"
|
||||
/>
|
||||
<div class="flex justify-center gap-6">
|
||||
<MalioButton variant="primary" :label="t('transport.carriers.edit.save')" :disabled="tabSubmitting" @click="onSubmitAddresses" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #contacts>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<CarrierContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
/>
|
||||
<div class="flex justify-center gap-6">
|
||||
<MalioButton variant="secondary" icon-name="mdi:add-bold" icon-position="left" :label="t('transport.carriers.form.contact.add')" :disabled="!canAddContact" @click="addContact" />
|
||||
<MalioButton variant="primary" :label="t('transport.carriers.edit.save')" :disabled="tabSubmitting" @click="onSubmitContacts" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #prices>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<CarrierPriceBlock
|
||||
v-for="(price, index) in prices"
|
||||
:key="index"
|
||||
:model-value="price"
|
||||
:client-options="clientOptions"
|
||||
:supplier-options="supplierOptions"
|
||||
:site-options="siteOptions"
|
||||
removable
|
||||
:errors="priceErrors[index]"
|
||||
@update:model-value="(v) => prices[index] = v"
|
||||
@remove="askRemovePrice(index)"
|
||||
/>
|
||||
<div class="flex justify-center gap-6">
|
||||
<MalioButton variant="secondary" icon-name="mdi:add-bold" icon-position="left" :label="t('transport.carriers.form.price.add')" :disabled="!canAddPrice" @click="addPrice" />
|
||||
<MalioButton variant="primary" :label="t('transport.carriers.edit.save')" :disabled="tabSubmitting" @click="onSubmitPrices" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MalioTabList>
|
||||
</template>
|
||||
|
||||
<!-- Modal de confirmation de suppression de bloc. -->
|
||||
<MalioModal v-model="deleteConfirm.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.confirmDelete.title') }}</h2>
|
||||
</template>
|
||||
<p>{{ t('transport.carriers.form.confirmDelete.message') }}</p>
|
||||
<template #footer>
|
||||
<MalioButton variant="secondary" button-class="flex-1" :label="t('transport.carriers.form.confirmDelete.cancel')" @click="deleteConfirm.open = false" />
|
||||
<MalioButton variant="danger" button-class="flex-1" :label="t('transport.carriers.form.confirmDelete.confirm')" @click="runDeleteConfirm" />
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
|
||||
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
|
||||
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
|
||||
import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTab.vue'
|
||||
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
||||
import { useCarrier } from '~/modules/transport/composables/useCarrier'
|
||||
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
|
||||
|
||||
interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const api = useApi()
|
||||
const { can } = usePermissions()
|
||||
|
||||
const carrierId = route.params.id as string
|
||||
useHead({ title: t('transport.carriers.edit.title') })
|
||||
|
||||
// Gating route : l'édition est réservée à `manage` ; sinon retour consultation.
|
||||
if (!can('transport.carriers.manage')) {
|
||||
await navigateTo(`/carriers/${carrierId}`)
|
||||
}
|
||||
|
||||
const { carrier, loading, error, load } = useCarrier(carrierId)
|
||||
|
||||
const {
|
||||
main,
|
||||
mainSubmitting,
|
||||
tabSubmitting,
|
||||
mainErrors,
|
||||
dischargeUploading,
|
||||
selectDischarge,
|
||||
clearDischarge,
|
||||
setCertification,
|
||||
isLiot,
|
||||
certificationReadonly,
|
||||
showCharteredFields,
|
||||
showDischarge,
|
||||
applyQualimatSelection,
|
||||
address,
|
||||
addressErrors,
|
||||
submitAddress,
|
||||
contacts,
|
||||
contactErrors,
|
||||
canAddContact,
|
||||
addContact,
|
||||
removeContact,
|
||||
submitContacts,
|
||||
prices,
|
||||
priceErrors,
|
||||
canAddPrice,
|
||||
addPrice,
|
||||
removePrice,
|
||||
submitPrices,
|
||||
updateMain,
|
||||
prefillFrom,
|
||||
} = useCarrierForm()
|
||||
|
||||
const SELECTABLE_CERTIFICATIONS = ['GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'] as const
|
||||
const certificationOptions = computed<SelectOption[]>(() => {
|
||||
const codes: string[] = [...SELECTABLE_CERTIFICATIONS]
|
||||
if (main.certificationType === 'QUALIMAT') codes.unshift('QUALIMAT')
|
||||
return codes.map(code => ({ value: code, label: t(`transport.carriers.certification.${code}`) }))
|
||||
})
|
||||
|
||||
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
qualimat: 'mdi:truck-fast-outline',
|
||||
addresses: 'mdi:map-marker-outline',
|
||||
contacts: 'mdi:account-box-plus-outline',
|
||||
prices: 'mdi:payment',
|
||||
}
|
||||
const activeTab = ref('addresses')
|
||||
// Onglet Qualimat disponible en modif (ERP-172) : « actualiser » nom + certification.
|
||||
const tabs = computed(() => ['qualimat', 'addresses', 'contacts', 'prices'].map(key => ({
|
||||
key,
|
||||
label: t(`transport.carriers.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
})))
|
||||
|
||||
// ── Référentiels (pays + clients / fournisseurs / sites pour l'onglet Prix) ───
|
||||
const countryOptions = ref<SelectOption[]>([{ value: 'France', label: 'France' }])
|
||||
const clientOptions = ref<SelectOption[]>([])
|
||||
const supplierOptions = ref<SelectOption[]>([])
|
||||
const siteOptions = ref<SelectOption[]>([])
|
||||
|
||||
async function loadOptions(url: string, target: typeof clientOptions, labelOf: (m: Record<string, unknown>) => string): Promise<void> {
|
||||
try {
|
||||
const data = await api.get<{ member?: Record<string, unknown>[] }>(url, { pagination: 'false' }, { headers: { Accept: 'application/ld+json' }, toast: false })
|
||||
target.value = (data.member ?? []).map(m => ({ value: String(m['@id']), label: labelOf(m) }))
|
||||
}
|
||||
catch {
|
||||
target.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCountries(): Promise<void> {
|
||||
try {
|
||||
const data = await api.get<{ member?: { name: string }[] }>('/countries', { pagination: 'false' }, { headers: { Accept: 'application/ld+json' }, toast: false })
|
||||
const list = (data.member ?? []).map(c => ({ value: c.name, label: c.name }))
|
||||
countryOptions.value = list.some(c => c.value === 'France') ? list : [{ value: 'France', label: 'France' }, ...list]
|
||||
}
|
||||
catch { /* fallback France */ }
|
||||
}
|
||||
|
||||
// ── Chargement + préremplissage ──────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
if (carrier.value) {
|
||||
prefillFrom(carrier.value)
|
||||
// Pré-affiche le nom du fichier de décharge déjà rattaché (s'il existe).
|
||||
const doc = carrier.value.dischargeDocument
|
||||
if (doc && typeof doc !== 'string') {
|
||||
const meta = doc as Record<string, unknown>
|
||||
dischargeFileName.value = String(meta.originalFilename ?? meta.name ?? '')
|
||||
}
|
||||
}
|
||||
loadCountries().catch(() => {})
|
||||
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']))
|
||||
void loadOptions('/suppliers', supplierOptions, m => String(m.companyName ?? m['@id']))
|
||||
void loadOptions('/sites', siteOptions, m => String(m.name ?? m['@id']))
|
||||
})
|
||||
|
||||
function apiErrorMessage(err: unknown): string {
|
||||
const data = (err as { response?: { _data?: unknown } })?.response?._data
|
||||
return extractApiErrorMessage(data) || t('transport.carriers.toast.error')
|
||||
}
|
||||
|
||||
// Nom de fichier affiché dans le champ Décharge (alimenté à la sélection ou au
|
||||
// chargement d'un transporteur ayant déjà une décharge).
|
||||
const dischargeFileName = ref('')
|
||||
|
||||
/** Vidage du champ Décharge : oublie le fichier en attente / l'IRI + le nom affiché. */
|
||||
function onClearDischarge(): void {
|
||||
clearDischarge()
|
||||
dischargeFileName.value = ''
|
||||
}
|
||||
|
||||
// Indexation plafonnée à 100 % : la clé force le ré-affichage du MalioInputAmount
|
||||
// (contrôlé) quand le plafonnement laisse le modelValue inchangé.
|
||||
const indexationKey = ref(0)
|
||||
|
||||
/** Saisie de l'indexation : plafonne à 100 et re-synchronise le champ si plafonné. */
|
||||
function onIndexationInput(value: string): void {
|
||||
const clamped = clampPercent(value)
|
||||
main.indexationRate = clamped
|
||||
if (clamped !== value) {
|
||||
indexationKey.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
function goBack(): void {
|
||||
router.push(`/carriers/${carrierId}`)
|
||||
}
|
||||
|
||||
/** PATCH du formulaire principal (pas de re-POST). */
|
||||
async function onUpdateMain(): Promise<void> {
|
||||
const ok = await updateMain()
|
||||
if (ok) {
|
||||
toast.success({ title: t('transport.carriers.toast.updateSuccess') })
|
||||
}
|
||||
}
|
||||
|
||||
/** Intégration d'une ligne QUALIMAT (onglet Qualimat) : actualise nom + certification
|
||||
* + FK via PATCH (applyQualimatSelection). L'adresse existante n'est pas touchée. */
|
||||
async function onIntegrateQualimat(row: QualimatCarrierRow): Promise<void> {
|
||||
const ok = await applyQualimatSelection(row)
|
||||
if (ok) {
|
||||
toast.success({ title: t('transport.carriers.toast.integrateSuccess') })
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmitAddresses(): Promise<void> {
|
||||
const ok = await submitAddress(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
|
||||
if (ok) toast.success({ title: t('transport.carriers.toast.addressSaved') })
|
||||
}
|
||||
async function onSubmitContacts(): Promise<void> {
|
||||
const ok = await submitContacts(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
|
||||
if (ok) toast.success({ title: t('transport.carriers.toast.contactSaved') })
|
||||
}
|
||||
async function onSubmitPrices(): Promise<void> {
|
||||
const ok = await submitPrices(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
|
||||
if (ok) toast.success({ title: t('transport.carriers.toast.priceSaved') })
|
||||
}
|
||||
|
||||
// ── Suppression de bloc (modal de confirmation générique) ────────────────────
|
||||
const deleteConfirm = reactive({ open: false, action: null as null | (() => void) })
|
||||
|
||||
function askRemoveContact(index: number): void {
|
||||
deleteConfirm.action = () => { void removeContact(index) }
|
||||
deleteConfirm.open = true
|
||||
}
|
||||
function askRemovePrice(index: number): void {
|
||||
deleteConfirm.action = () => { void removePrice(index) }
|
||||
deleteConfirm.open = true
|
||||
}
|
||||
function runDeleteConfirm(): void {
|
||||
deleteConfirm.action?.()
|
||||
deleteConfirm.action = null
|
||||
deleteConfirm.open = false
|
||||
}
|
||||
|
||||
function onAddressDegraded(): void {
|
||||
toast.warning({ title: t('transport.carriers.toast.error'), message: t('transport.carriers.form.address.degraded') })
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,506 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tête : retour répertoire + nom + actions. -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
v-bind="{ ariaLabel: t('transport.carriers.consultation.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
|
||||
|
||||
<div class="ml-auto flex items-center gap-12">
|
||||
<MalioButton
|
||||
v-if="canEdit"
|
||||
variant="secondary"
|
||||
icon-name="mdi:pencil-outline"
|
||||
icon-position="left"
|
||||
:label="t('transport.carriers.action.edit')"
|
||||
@click="goEdit"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showArchive"
|
||||
variant="secondary"
|
||||
icon-name="mdi:archive-arrow-down-outline"
|
||||
icon-position="left"
|
||||
:label="t('transport.carriers.action.archive')"
|
||||
@click="askToggleArchive"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showRestore"
|
||||
variant="secondary"
|
||||
icon-name="mdi:archive-arrow-up-outline"
|
||||
icon-position="left"
|
||||
:label="t('transport.carriers.action.restore')"
|
||||
@click="askToggleArchive"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('transport.carriers.consultation.loading') }}</p>
|
||||
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('transport.carriers.consultation.notFound') }}</p>
|
||||
|
||||
<template v-else-if="carrier">
|
||||
<!-- ── Bloc principal (lecture seule) — même disposition que l'ajout ── -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText :model-value="main.name" :label="t('transport.carriers.form.main.name')" readonly />
|
||||
|
||||
<!-- Cas LIOT : seul le champ immatriculations. -->
|
||||
<MalioInputText
|
||||
v-if="isLiot"
|
||||
:model-value="main.liotPlates"
|
||||
:label="t('transport.carriers.form.main.liotPlates')"
|
||||
readonly
|
||||
/>
|
||||
|
||||
<!-- Cas standard : certification + décharge (col 3 réservée) + affrètement (col 4). -->
|
||||
<template v-if="!isLiot">
|
||||
<MalioInputText
|
||||
:model-value="certificationLabel"
|
||||
:label="t('transport.carriers.form.main.certificationType')"
|
||||
readonly
|
||||
/>
|
||||
|
||||
<!-- Colonne 3 réservée à la décharge (si AUTRE), sinon vide (xl). -->
|
||||
<MalioInputText
|
||||
v-if="main.certificationType === 'AUTRE'"
|
||||
:model-value="dischargeLabel"
|
||||
:label="t('transport.carriers.form.main.discharge')"
|
||||
readonly
|
||||
/>
|
||||
<div v-else class="hidden xl:block"></div>
|
||||
|
||||
<!-- Affréter : colonne 4, centré (h-12) comme à l'ajout. -->
|
||||
<div class="flex h-12 items-center">
|
||||
<MalioCheckbox
|
||||
id="carrier-view-chartered"
|
||||
:label="t('transport.carriers.form.main.isChartered')"
|
||||
:model-value="main.isChartered"
|
||||
readonly
|
||||
:reserve-message-space="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Champs d'affrètement (ligne 2) si affrété. -->
|
||||
<template v-if="main.isChartered">
|
||||
<MalioInputText :model-value="indexationDisplay" :label="t('transport.carriers.form.main.indexationRate')" readonly />
|
||||
<!-- Contenant : radios désactivés (lecture seule), aligné sur l'ajout / la modif. -->
|
||||
<div>
|
||||
<div class="flex h-12 items-center gap-4">
|
||||
<MalioRadioButton
|
||||
:model-value="main.containerType"
|
||||
name="carrier-view-container"
|
||||
value="BENNE"
|
||||
:label="t('transport.carriers.containerType.BENNE')"
|
||||
readonly
|
||||
group-class="mt-0"
|
||||
/>
|
||||
<MalioRadioButton
|
||||
:model-value="main.containerType"
|
||||
name="carrier-view-container"
|
||||
value="FOND_MOUVANT"
|
||||
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
||||
readonly
|
||||
group-class="mt-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MalioInputText :model-value="main.volumeM3" :label="t('transport.carriers.form.main.volumeM3')" readonly />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets (Adresses · Contacts · Prix) — ouvre sur Adresses ──── -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||
<template #addresses>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- Adresse UNIQUE (ERP-172). -->
|
||||
<CarrierAddressBlock
|
||||
:model-value="address"
|
||||
:country-options="countryOptionsFor(address.country)"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #contacts>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<CarrierContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Prix : tableau présentationnel regroupé par contenant + export. -->
|
||||
<template #prices>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- Police / bordures / radius alignés sur MalioDataTable (header
|
||||
16px, corps 14px). 1re colonne « Contenant » : libellé du
|
||||
groupe (Fond Mouvant / Benne) fusionné en rowspan ; séparateur
|
||||
épais entre les deux groupes. -->
|
||||
<table class="w-full table-fixed border-separate border-spacing-0 overflow-hidden rounded-malio border border-black text-left text-black">
|
||||
<!-- Répartition (table-fixed) : « Type de transport » un peu plus
|
||||
large ; Transporteurs et Adresse livraisons larges ; Forfait /
|
||||
Tonne / Indexation / État réduits. -->
|
||||
<colgroup>
|
||||
<col class="w-[170px]" />
|
||||
<col class="w-[20%]" />
|
||||
<col class="w-[11%]" />
|
||||
<col class="w-[24%]" />
|
||||
<col class="w-[9%]" />
|
||||
<col class="w-[9%]" />
|
||||
<col class="w-[9%]" />
|
||||
<col class="w-[9%]" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<!-- En-tête centré pour matcher les cellules fusionnées Benne / Fond mouvant. -->
|
||||
<th class="border-b border-r border-black bg-m-surface px-3 py-3 text-center align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.group') }}</th>
|
||||
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.carrier') }}</th>
|
||||
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.aproOrSite') }}</th>
|
||||
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.delivery') }}</th>
|
||||
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.forfait') }}</th>
|
||||
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.tonne') }}</th>
|
||||
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.indexation') }}</th>
|
||||
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.state') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="(group, gi) in priceGroups" :key="group.label">
|
||||
<tr
|
||||
v-for="(row, i) in group.rows"
|
||||
:key="`${gi}-${i}`"
|
||||
>
|
||||
<!-- Cellule de groupe fusionnée (rowspan), centrée verticalement ;
|
||||
séparateur épais en bas entre les groupes (sauf dernier). -->
|
||||
<td
|
||||
v-if="i === 0"
|
||||
:rowspan="group.rows.length"
|
||||
class="border-r border-black px-3 text-center align-middle text-[14px] font-medium"
|
||||
:class="groupBorder(gi)"
|
||||
>
|
||||
{{ group.label }}
|
||||
</td>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ headerTitle }}</td>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.apro }}</td>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.delivery }}</td>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.forfait }}</td>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.tonne }}</td>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.indexation }}</td>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.state }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="!hasPrices">
|
||||
<td colspan="8" class="px-3 py-4 text-center text-[14px] text-m-muted">
|
||||
{{ t('transport.carriers.consultation.price.empty') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="hasPrices" class="flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('transport.carriers.consultation.price.export')"
|
||||
:disabled="exporting"
|
||||
@click="exportPrices"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MalioTabList>
|
||||
</template>
|
||||
|
||||
<!-- Modal de confirmation archivage / restauration. -->
|
||||
<MalioModal v-model="confirmArchive.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ confirmArchive.title }}</h2>
|
||||
</template>
|
||||
<p>{{ confirmArchive.message }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('transport.carriers.form.confirmDelete.cancel')"
|
||||
@click="confirmArchive.open = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="flex-1"
|
||||
:label="confirmArchive.confirmLabel"
|
||||
@click="runToggleArchive"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
|
||||
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
|
||||
import { useCarrier } from '~/modules/transport/composables/useCarrier'
|
||||
import {
|
||||
canEditCarrier,
|
||||
labelOfRelation,
|
||||
mapAddressToDraft,
|
||||
mapContactToDraft,
|
||||
mapMainToDraft,
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
type CarrierPriceRead,
|
||||
type Relation,
|
||||
} from '~/modules/transport/utils/forms/carrierMappers'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
|
||||
interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const api = useApi()
|
||||
const { can } = usePermissions()
|
||||
|
||||
const carrierId = route.params.id as string
|
||||
const { carrier, loading, error, load, archive, restore } = useCarrier(carrierId)
|
||||
|
||||
const isArchived = computed(() => carrier.value?.isArchived ?? false)
|
||||
const canEdit = computed(() => canEditCarrier(can))
|
||||
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
|
||||
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
|
||||
|
||||
const headerTitle = computed(() => carrier.value?.name || t('transport.carriers.consultation.title'))
|
||||
useHead({ title: t('transport.carriers.consultation.title') })
|
||||
|
||||
// ── Bloc principal mappé (lecture seule) ─────────────────────────────────────
|
||||
const main = computed(() => mapMainToDraft(carrier.value ?? { id: 0, '@id': '' }))
|
||||
const isLiot = computed(() => main.value.name.trim().toUpperCase() === 'LIOT')
|
||||
const certificationLabel = computed(() => main.value.certificationType
|
||||
? t(`transport.carriers.certification.${main.value.certificationType}`)
|
||||
: '')
|
||||
// Indexation affichée avec le « % » (comme l'icône du champ amount de l'ajout).
|
||||
const indexationDisplay = computed(() => main.value.indexationRate ? `${main.value.indexationRate} %` : '')
|
||||
// Décharge : nom du fichier embarqué si présent (sinon vide ; la colonne reste réservée).
|
||||
const dischargeLabel = computed(() => {
|
||||
const doc = carrier.value?.dischargeDocument
|
||||
if (doc && typeof doc !== 'string') {
|
||||
const meta = doc as Record<string, unknown>
|
||||
return String(meta.originalFilename ?? meta.name ?? '')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
// ── Onglets : Adresses · Contacts · Prix (ouvre sur Adresses, pas de Qualimat) ──
|
||||
const activeTab = ref('addresses')
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
addresses: 'mdi:map-marker-outline',
|
||||
contacts: 'mdi:account-box-plus-outline',
|
||||
prices: 'mdi:payment',
|
||||
}
|
||||
const tabs = computed(() => ['addresses', 'contacts', 'prices'].map(key => ({
|
||||
key,
|
||||
label: t(`transport.carriers.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
})))
|
||||
|
||||
// Adresse UNIQUE (ERP-172) : un seul bloc en lecture seule (vide si pas d'adresse).
|
||||
const address = computed(() => carrier.value?.address
|
||||
? mapAddressToDraft(carrier.value.address)
|
||||
: mapAddressToDraft({ id: 0, '@id': '' }))
|
||||
const contacts = computed(() => {
|
||||
const list = (carrier.value?.contacts ?? []).map(mapContactToDraft)
|
||||
return list.length > 0 ? list : [mapContactToDraft({ id: 0, '@id': '' })]
|
||||
})
|
||||
|
||||
/** Pays : une seule option (valeur courante), suffisant pour l'affichage readonly. */
|
||||
function countryOptionsFor(country: string): SelectOption[] {
|
||||
return country ? [{ value: country, label: country }] : []
|
||||
}
|
||||
|
||||
// ── Tableau Prix consultation (regroupé par contenant Fond Mouvant / Benne) ───
|
||||
const PRICE_GROUP_ORDER = ['FOND_MOUVANT', 'BENNE'] as const
|
||||
|
||||
interface PriceRowView {
|
||||
apro: string
|
||||
delivery: string
|
||||
forfait: string
|
||||
tonne: string
|
||||
indexation: string
|
||||
state: string
|
||||
}
|
||||
|
||||
/** Groupe de prix d'un même contenant (Fond Mouvant / Benne) — cellule fusionnée. */
|
||||
interface PriceGroupView {
|
||||
label: string
|
||||
rows: PriceRowView[]
|
||||
}
|
||||
|
||||
/** Formate un montant décimal en « 1 000,00 € » (chaîne vide si absent). */
|
||||
function formatAmount(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
const n = Number(value)
|
||||
if (Number.isNaN(n)) {
|
||||
return ''
|
||||
}
|
||||
return `${n.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
|
||||
}
|
||||
|
||||
/** Code du site = département (2 premiers chiffres du code postal, ex: 86 / 17 / 82). */
|
||||
function siteCode(relation: Relation): string {
|
||||
if (!relation || typeof relation === 'string') {
|
||||
return ''
|
||||
}
|
||||
const postalCode = relation.postalCode as string | undefined
|
||||
return postalCode ? postalCode.slice(0, 2) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit une ligne d'affichage depuis un prix embarqué (maquette Prix) :
|
||||
* - « Adresse sites » = le CODE du site (département, ex: 86 / 17 / 82) ;
|
||||
* - « Adresse livraisons » = l'adresse (voie) du client/fournisseur ;
|
||||
* - le prix tombe dans Forfait € OU Tonne € selon `pricingUnit`.
|
||||
*/
|
||||
function toPriceRow(price: CarrierPriceRead): PriceRowView {
|
||||
const isClient = price.direction === 'CLIENT'
|
||||
return {
|
||||
apro: isClient ? siteCode(price.departureSite) : siteCode(price.deliverySite),
|
||||
delivery: isClient ? labelOfRelation(price.clientDeliveryAddress) : labelOfRelation(price.supplierSupplyAddress),
|
||||
forfait: price.pricingUnit === 'FORFAIT' ? formatAmount(price.price) : '',
|
||||
tonne: price.pricingUnit === 'TONNE' ? formatAmount(price.price) : '',
|
||||
// CarrierPrice n'a pas de taux d'indexation propre → on affiche celui du
|
||||
// transporteur (formulaire principal). À faire évoluer si un taux par prix
|
||||
// est requis (gap back).
|
||||
indexation: main.value.indexationRate ? `${main.value.indexationRate} %` : '',
|
||||
state: price.priceState ? t(`transport.carriers.form.price.state${stateSuffix(price.priceState)}`) : '',
|
||||
}
|
||||
}
|
||||
|
||||
/** EN_COURS → EnCours, VALIDE → Valide, NON_VALIDE → NonValide (clés i18n existantes). */
|
||||
function stateSuffix(state: string): string {
|
||||
const map: Record<string, string> = { EN_COURS: 'EnCours', VALIDE: 'Valide', NON_VALIDE: 'NonValide' }
|
||||
return map[state] ?? ''
|
||||
}
|
||||
|
||||
// Prix regroupés par contenant (Fond Mouvant puis Benne) — une cellule fusionnée
|
||||
// par groupe (rowspan) à gauche, conformément à la maquette.
|
||||
const priceGroups = computed<PriceGroupView[]>(() => {
|
||||
const list = carrier.value?.prices ?? []
|
||||
return PRICE_GROUP_ORDER
|
||||
.map(container => ({
|
||||
label: t(`transport.carriers.containerType.${container}`),
|
||||
rows: list.filter(p => p.containerType === container).map(toPriceRow),
|
||||
}))
|
||||
.filter(group => group.rows.length > 0)
|
||||
})
|
||||
|
||||
const hasPrices = computed(() => priceGroups.value.length > 0)
|
||||
|
||||
/**
|
||||
* Bordure basse d'une cellule de données :
|
||||
* - ligne interne d'un groupe → fine grise ;
|
||||
* - dernière ligne d'un groupe NON final → épaisse noire (séparateur de groupe) ;
|
||||
* - dernière ligne du DERNIER groupe → aucune (le cadre du tableau s'en charge,
|
||||
* évite la double bordure tout en bas).
|
||||
*/
|
||||
function dataBorder(group: PriceGroupView, i: number, gi: number): string {
|
||||
const isLastRow = i === group.rows.length - 1
|
||||
const isLastGroup = gi === priceGroups.value.length - 1
|
||||
if (!isLastRow) {
|
||||
return 'border-b border-m-muted/30'
|
||||
}
|
||||
return isLastGroup ? '' : 'border-b-2 border-black'
|
||||
}
|
||||
|
||||
/** Bordure basse de la cellule de groupe fusionnée (séparateur épais sauf dernier groupe). */
|
||||
function groupBorder(gi: number): string {
|
||||
return gi === priceGroups.value.length - 1 ? '' : 'border-b-2 border-black'
|
||||
}
|
||||
|
||||
// ── Export XLSX des prix ─────────────────────────────────────────────────────
|
||||
const exporting = ref(false)
|
||||
|
||||
async function exportPrices(): Promise<void> {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
const blob = await api.get<Blob>(`/carriers/${carrierId}/prices/export.xlsx`, {}, {
|
||||
responseType: 'blob',
|
||||
toast: false,
|
||||
} as unknown as Parameters<typeof api.get>[2])
|
||||
triggerDownload(blob, `transporteur-${carrierId}-prix.xlsx`)
|
||||
}
|
||||
catch {
|
||||
toast.error({ title: t('transport.carriers.toast.error'), message: t('transport.carriers.toast.exportError') })
|
||||
}
|
||||
finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// ── Navigation / archivage ───────────────────────────────────────────────────
|
||||
function goBack(): void {
|
||||
router.push('/carriers')
|
||||
}
|
||||
|
||||
function goEdit(): void {
|
||||
router.push(`/carriers/${carrierId}/edit`)
|
||||
}
|
||||
|
||||
const confirmArchive = reactive({ open: false, title: '', message: '', confirmLabel: '' })
|
||||
|
||||
function askToggleArchive(): void {
|
||||
const archiving = !isArchived.value
|
||||
confirmArchive.title = archiving ? t('transport.carriers.action.archive') : t('transport.carriers.action.restore')
|
||||
confirmArchive.message = archiving
|
||||
? t('transport.carriers.consultation.confirmArchive.message')
|
||||
: t('transport.carriers.consultation.confirmRestore.message')
|
||||
confirmArchive.confirmLabel = archiving ? t('transport.carriers.action.archive') : t('transport.carriers.action.restore')
|
||||
confirmArchive.open = true
|
||||
}
|
||||
|
||||
async function runToggleArchive(): Promise<void> {
|
||||
const archiving = !isArchived.value
|
||||
confirmArchive.open = false
|
||||
try {
|
||||
await (archiving ? archive() : restore())
|
||||
toast.success({
|
||||
title: archiving
|
||||
? t('transport.carriers.toast.archiveSuccess')
|
||||
: t('transport.carriers.toast.restoreSuccess'),
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
// Surface le message back (ex. 409 « homonyme actif » à la restauration),
|
||||
// propagé exprès par useCarrier ; fallback générique sinon.
|
||||
const data = (err as { response?: { _data?: unknown } })?.response?._data
|
||||
toast.error({
|
||||
title: t('transport.carriers.toast.error'),
|
||||
message: extractApiErrorMessage(data) || undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -53,18 +53,20 @@
|
||||
<!-- Colonne 3 RÉSERVÉE à la Décharge (RG-4.02 : visible et obligatoire
|
||||
si certification AUTRE). Si elle n'apparaît pas, on garde la colonne
|
||||
vide (xl) pour qu'« Affréter » reste en colonne 4 de la ligne 1.
|
||||
L'upload reel (File → IRI via useUpload) arrive a ERP-171. -->
|
||||
<!-- TODO ERP-171 : brancher useUpload pour resoudre le File en IRI
|
||||
(main.dischargeDocumentIri). Le champ est deja visible/obligatoire. -->
|
||||
Upload DIFFÉRÉ (ERP-171) : le fichier choisi est mis en attente
|
||||
et envoyé seulement à la validation du formulaire. -->
|
||||
<MalioInputUpload
|
||||
v-if="showDischarge"
|
||||
:model-value="dischargeFileName"
|
||||
:label="t('transport.carriers.form.main.discharge')"
|
||||
accept="application/pdf,image/*"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:readonly="mainLocked || dischargeUploading"
|
||||
:clearable="true"
|
||||
:error="mainErrors.errors.dischargeDocument"
|
||||
@clear="main.dischargeDocumentIri = null"
|
||||
@update:model-value="(v: string) => dischargeFileName = v"
|
||||
@file-selected="selectDischarge"
|
||||
@clear="onClearDischarge"
|
||||
/>
|
||||
<div v-else class="hidden xl:block"></div>
|
||||
|
||||
@@ -86,32 +88,55 @@
|
||||
« Affreter ». La ligne 1 étant pleine (4 colonnes), ils démarrent
|
||||
naturellement en colonne 1 de la ligne 2. -->
|
||||
<template v-if="showCharteredFields">
|
||||
<MalioInputNumber
|
||||
v-model="main.indexationRate"
|
||||
<!-- Indexation : montant en % (icône à droite), plafonné à 100. La
|
||||
:key force le ré-affichage du champ contrôlé quand on plafonne
|
||||
(sinon le modelValue inchangé n'est pas re-synchronisé par Vue). -->
|
||||
<MalioInputAmount
|
||||
:key="indexationKey"
|
||||
:model-value="main.indexationRate"
|
||||
:label="t('transport.carriers.form.main.indexationRate')"
|
||||
icon-name="mdi:percent"
|
||||
icon-position="right"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:error="mainErrors.errors.indexationRate"
|
||||
@update:model-value="onIndexationInput"
|
||||
/>
|
||||
|
||||
<!-- Contenant : Benne / Fond mouvant (RG-4.03). -->
|
||||
<MalioSelect
|
||||
:model-value="main.containerType"
|
||||
:options="containerOptions"
|
||||
:label="t('transport.carriers.form.main.containerType')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:error="mainErrors.errors.containerType"
|
||||
@update:model-value="(v: string | number | null) => main.containerType = v === null ? null : String(v)"
|
||||
/>
|
||||
<!-- Contenant : Benne / Fond mouvant en radios, centrés (h-12) comme
|
||||
à l'onglet Prix (Benne par défaut). -->
|
||||
<div>
|
||||
<div class="flex h-12 items-center gap-4">
|
||||
<MalioRadioButton
|
||||
:model-value="main.containerType"
|
||||
name="carrier-main-container"
|
||||
value="BENNE"
|
||||
:label="t('transport.carriers.containerType.BENNE')"
|
||||
:disabled="mainLocked"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioRadioButton
|
||||
:model-value="main.containerType"
|
||||
name="carrier-main-container"
|
||||
value="FOND_MOUVANT"
|
||||
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
||||
:disabled="mainLocked"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="mainErrors.errors.containerType" class="ml-[2px] text-xs text-m-danger">{{ mainErrors.errors.containerType }}</p>
|
||||
</div>
|
||||
|
||||
<MalioInputNumber
|
||||
v-model="main.volumeM3"
|
||||
<!-- Volume m³ : champ texte restreint aux nombres à décimales (point). -->
|
||||
<MalioInputText
|
||||
:model-value="main.volumeM3"
|
||||
:label="t('transport.carriers.form.main.volumeM3')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:error="mainErrors.errors.volumeM3"
|
||||
@update:model-value="(v: string) => main.volumeM3 = sanitizeDecimal(v)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
@@ -131,72 +156,32 @@
|
||||
assistee (table de selection) ; Adresses / Contacts / Prix arrivent aux
|
||||
tickets suivants (placeholders « A venir »). -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||
<!-- Onglet Qualimat : datatable paginé filtré par le NOM du transporteur
|
||||
(pas de champ de recherche dédié — RG-4.01 / 4.04). -->
|
||||
<!-- Onglet Qualimat : saisie assistée (recherche par nom). Composant
|
||||
mutualisé avec l'écran de modification (ERP-172). -->
|
||||
<template #qualimat>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<MalioDataTable
|
||||
:columns="qualimatColumns"
|
||||
:items="qualimatRows"
|
||||
:total-items="qualimatTotalDisplay"
|
||||
:page="qualimatPage"
|
||||
:per-page="qualimatPerPage"
|
||||
:per-page-options="qualimatPerPageOptions"
|
||||
row-clickable
|
||||
:empty-message="qualimatEmptyMessage"
|
||||
@row-click="onQualimatRowClick"
|
||||
@update:page="qualimatGoToPage"
|
||||
@update:per-page="qualimatSetPerPage"
|
||||
>
|
||||
<!-- Radio reflétant la ligne QUALIMAT intégrée (lecture). -->
|
||||
<template #cell-select="{ item }">
|
||||
<MalioRadioButton
|
||||
:model-value="main.qualimatCarrierIri"
|
||||
name="qualimat-row"
|
||||
:value="item.iri"
|
||||
group-class="mt-0"
|
||||
/>
|
||||
</template>
|
||||
<!-- Date de validité : fond rouge si périmée (RG-4.04). -->
|
||||
<template #cell-validityDate="{ item }">
|
||||
<span
|
||||
v-if="item.validityDate"
|
||||
:class="isExpired(item.validityDate as string) ? 'inline-block rounded px-2 py-0.5 bg-m-danger text-white' : ''"
|
||||
>
|
||||
{{ formatDateFr(item.validityDate as string) }}
|
||||
</span>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
<CarrierQualimatTab
|
||||
:search-name="main.name"
|
||||
:selected-iri="main.qualimatCarrierIri"
|
||||
@integrate="onIntegrateQualimat"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Adresses (ERP-167) : un bloc par adresse + BAN. RG-4.05
|
||||
préremplissage si QUALIMAT ; RG-4.07 pas de Valider si QUALIMAT. -->
|
||||
<template #addresses>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
|
||||
<CarrierAddressBlock
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="isQualimat || isValidated('addresses')"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
:errors="addressErrors"
|
||||
@update:model-value="(v) => address = v"
|
||||
@degraded="onAddressDegraded"
|
||||
/>
|
||||
<!-- RG-4.07 : pas de bouton Valider pour un transporteur QUALIMAT
|
||||
(adresse copiée et persistée automatiquement). -->
|
||||
<div v-if="!isQualimat && !isValidated('addresses')" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('transport.carriers.form.address.add')"
|
||||
:disabled="!canAddAddress"
|
||||
@click="addAddress"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('transport.carriers.form.submit')"
|
||||
@@ -207,7 +192,76 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Contacts / Prix : contenu aux tickets suivants. -->
|
||||
<!-- Onglet Contacts (ERP-168) : un bloc par contact (RG-4.08 ≥ 1 champ,
|
||||
max 2 téléphones). Erreurs 422 par ligne. -->
|
||||
<template #contacts>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<CarrierContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="isValidated('contacts')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
/>
|
||||
<div v-if="!isValidated('contacts')" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('transport.carriers.form.contact.add')"
|
||||
:disabled="!canAddContact"
|
||||
@click="addContact"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('transport.carriers.form.submit')"
|
||||
:disabled="tabSubmitting || carrierId === null"
|
||||
@click="onSubmitContacts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Prix (ERP-169) : blocs multiples, branche CLIENT/FOURNISSEUR
|
||||
(RG-4.09→4.11). Démarre vide ; l'utilisateur ajoute via « + Nouveau prix ». -->
|
||||
<template #prices>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<CarrierPriceBlock
|
||||
v-for="(price, index) in prices"
|
||||
:key="index"
|
||||
:model-value="price"
|
||||
:client-options="clientOptions"
|
||||
:supplier-options="supplierOptions"
|
||||
:site-options="siteOptions"
|
||||
:removable="!isValidated('prices')"
|
||||
:readonly="isValidated('prices')"
|
||||
:errors="priceErrors[index]"
|
||||
@update:model-value="(v) => prices[index] = v"
|
||||
@remove="askRemovePrice(index)"
|
||||
/>
|
||||
<div v-if="!isValidated('prices')" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('transport.carriers.form.price.add')"
|
||||
:disabled="!canAddPrice"
|
||||
@click="addPrice"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('transport.carriers.form.submit')"
|
||||
:disabled="tabSubmitting || carrierId === null"
|
||||
@click="onSubmitPrices"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Plus d'onglet placeholder : tous les onglets ont leur contenu. -->
|
||||
<template
|
||||
v-for="key in placeholderTabs"
|
||||
:key="key"
|
||||
@@ -219,29 +273,7 @@
|
||||
</template>
|
||||
</MalioTabList>
|
||||
|
||||
<!-- Modal de confirmation d'integration QUALIMAT (RG-4.01). -->
|
||||
<MalioModal v-model="confirmOpen" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.qualimat.confirm.title') }}</h2>
|
||||
</template>
|
||||
<p>{{ t('transport.carriers.form.qualimat.confirm.message') }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('transport.carriers.form.qualimat.confirm.cancel')"
|
||||
@click="confirmOpen = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
button-class="flex-1"
|
||||
:label="t('transport.carriers.form.qualimat.confirm.confirm')"
|
||||
@click="confirmIntegrate"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
|
||||
<!-- Modal de confirmation de suppression (bloc adresse). -->
|
||||
<!-- Modal de confirmation de suppression (bloc contact / prix). -->
|
||||
<MalioModal v-model="deleteConfirm.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.confirmDelete.title') }}</h2>
|
||||
@@ -266,13 +298,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { debounce } from '~/shared/utils/debounce'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
|
||||
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
|
||||
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
|
||||
import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTab.vue'
|
||||
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
||||
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
|
||||
|
||||
interface SelectOption {
|
||||
value: string
|
||||
@@ -300,6 +335,9 @@ const {
|
||||
mainSubmitting,
|
||||
tabSubmitting,
|
||||
mainErrors,
|
||||
dischargeUploading,
|
||||
selectDischarge,
|
||||
clearDischarge,
|
||||
isLiot,
|
||||
isQualimat,
|
||||
certificationReadonly,
|
||||
@@ -309,26 +347,33 @@ const {
|
||||
activeTab,
|
||||
unlockedIndex,
|
||||
isValidated,
|
||||
addresses,
|
||||
address,
|
||||
addressErrors,
|
||||
canAddAddress,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
submitAddresses,
|
||||
submitAddress,
|
||||
contacts,
|
||||
contactErrors,
|
||||
canAddContact,
|
||||
addContact,
|
||||
removeContact,
|
||||
submitContacts,
|
||||
prices,
|
||||
priceErrors,
|
||||
canAddPrice,
|
||||
addPrice,
|
||||
removePrice,
|
||||
submitPrices,
|
||||
submitMain,
|
||||
applyQualimatSelection,
|
||||
} = useCarrierForm()
|
||||
|
||||
const {
|
||||
items: qualimatItems,
|
||||
totalItems: qualimatTotal,
|
||||
currentPage: qualimatPage,
|
||||
itemsPerPage: qualimatPerPage,
|
||||
itemsPerPageOptions: qualimatPerPageOptions,
|
||||
goToPage: qualimatGoToPage,
|
||||
setItemsPerPage: qualimatSetPerPage,
|
||||
setFilters: qualimatSetFilters,
|
||||
} = useQualimatSearch()
|
||||
// Nom de fichier affiché dans le champ Décharge (alimenté à la sélection).
|
||||
const dischargeFileName = ref('')
|
||||
|
||||
/** Vidage du champ Décharge : oublie le fichier en attente / l'IRI + le nom affiché. */
|
||||
function onClearDischarge(): void {
|
||||
clearDischarge()
|
||||
dischargeFileName.value = ''
|
||||
}
|
||||
|
||||
// Certifications selectionnables manuellement (spec § Formulaire principal) :
|
||||
// GMP+ / OVOCOM / Compte-propre / Autre. QUALIMAT n'y figure PAS — il est posé par
|
||||
@@ -348,55 +393,12 @@ const certificationOptions = computed<SelectOption[]>(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
// Colonnes du datatable de selection QUALIMAT (radio / Nom / Adresse / Validite).
|
||||
const qualimatColumns = [
|
||||
{ key: 'select', label: '' },
|
||||
{ key: 'name', label: t('transport.carriers.form.qualimat.columns.name') },
|
||||
{ key: 'address', label: t('transport.carriers.form.qualimat.columns.address') },
|
||||
{ key: 'validityDate', label: t('transport.carriers.form.qualimat.columns.validityDate') },
|
||||
]
|
||||
|
||||
// Le datatable n'affiche QUE des résultats de recherche : vide tant que le Nom n'est
|
||||
// pas saisi (pas de liste complète par défaut). `main.name` pilote l'affichage.
|
||||
const hasQualimatSearch = computed(() => main.name.trim() !== '')
|
||||
|
||||
// Lignes « plates » pour MalioDataTable (l'IRI sert au radio + à retrouver la ligne
|
||||
// source au clic). Le détail QUALIMAT complet reste dans `qualimatItems`.
|
||||
const qualimatRows = computed(() => {
|
||||
if (!hasQualimatSearch.value) {
|
||||
return []
|
||||
}
|
||||
return qualimatItems.value.map(row => ({
|
||||
id: row.id,
|
||||
iri: row['@id'],
|
||||
name: row.name,
|
||||
address: formatQualimatAddress(row),
|
||||
validityDate: row.validityDate,
|
||||
}))
|
||||
})
|
||||
|
||||
// Total / message vide alignés sur « tableau vide tant qu'on n'a pas recherché ».
|
||||
const qualimatTotalDisplay = computed(() => (hasQualimatSearch.value ? qualimatTotal.value : 0))
|
||||
const qualimatEmptyMessage = computed(() => hasQualimatSearch.value
|
||||
? t('transport.carriers.form.qualimat.empty')
|
||||
: t('transport.carriers.form.qualimat.searchHint'))
|
||||
|
||||
// Contenant (RG-4.03) : Benne / Fond mouvant — select simple.
|
||||
const CONTAINER_TYPES = ['BENNE', 'FOND_MOUVANT'] as const
|
||||
|
||||
const containerOptions = computed<SelectOption[]>(() =>
|
||||
CONTAINER_TYPES.map(code => ({
|
||||
value: code,
|
||||
label: t(`transport.carriers.containerType.${code}`),
|
||||
})),
|
||||
)
|
||||
|
||||
// Icone (Iconify) affichee dans chaque onglet, par cle.
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
qualimat: 'mdi:truck-check-outline',
|
||||
qualimat: 'mdi:truck-fast-outline',
|
||||
addresses: 'mdi:map-marker-outline',
|
||||
contacts: 'mdi:account-box-plus-outline',
|
||||
prices: 'mdi:currency-eur',
|
||||
prices: 'mdi:payment',
|
||||
}
|
||||
|
||||
// Onglets desactives tant que le formulaire principal n'est pas valide
|
||||
@@ -408,8 +410,41 @@ const tabs = computed(() => tabKeys.value.map((key, index) => ({
|
||||
disabled: index > unlockedIndex.value,
|
||||
})))
|
||||
|
||||
// Onglets dont le contenu arrive aux tickets suivants (Contacts / Prix).
|
||||
const placeholderTabs = computed(() => tabKeys.value.filter(key => key !== 'qualimat' && key !== 'addresses'))
|
||||
// Tous les onglets ont désormais leur contenu (qualimat / addresses / contacts / prices).
|
||||
const placeholderTabs = computed(() => tabKeys.value.filter(
|
||||
key => key !== 'qualimat' && key !== 'addresses' && key !== 'contacts' && key !== 'prices',
|
||||
))
|
||||
|
||||
// ── Référentiels de l'onglet Prix (clients / fournisseurs / sites) ───────────
|
||||
const clientOptions = ref<SelectOption[]>([])
|
||||
const supplierOptions = ref<SelectOption[]>([])
|
||||
const siteOptions = ref<SelectOption[]>([])
|
||||
|
||||
/** Charge un référentiel paginé (?pagination=false) et mappe en options { IRI, libellé }. */
|
||||
async function loadOptions(
|
||||
url: string,
|
||||
target: typeof clientOptions,
|
||||
labelOf: (m: Record<string, unknown>) => string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const data = await api.get<{ member?: Record<string, unknown>[] }>(
|
||||
url,
|
||||
{ pagination: 'false' },
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
target.value = (data.member ?? []).map(m => ({ value: String(m['@id']), label: labelOf(m) }))
|
||||
}
|
||||
catch {
|
||||
target.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/** Charge les référentiels de l'onglet Prix (non bloquant : selects vides si échec). */
|
||||
function loadPriceReferentials(): void {
|
||||
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']))
|
||||
void loadOptions('/suppliers', supplierOptions, m => String(m.companyName ?? m['@id']))
|
||||
void loadOptions('/sites', siteOptions, m => String(m.name ?? m['@id']))
|
||||
}
|
||||
|
||||
// ── Onglet Adresses (ERP-167) ────────────────────────────────────────────────
|
||||
// Pays : France garantie en tete meme si /countries echoue (resilience), pour
|
||||
@@ -436,6 +471,7 @@ async function loadCountries(): Promise<void> {
|
||||
|
||||
onMounted(() => {
|
||||
loadCountries().catch(() => {})
|
||||
loadPriceReferentials()
|
||||
})
|
||||
|
||||
// Avertissement unique quand l'autocompletion d'adresse bascule en degrade.
|
||||
@@ -460,7 +496,7 @@ function apiErrorMessage(error: unknown): string {
|
||||
|
||||
/** Valide l'onglet Adresses (POST/PATCH par ligne ; avance gere par le composable). */
|
||||
async function onSubmitAddresses(): Promise<void> {
|
||||
const ok = await submitAddresses(error => toast.error({
|
||||
const ok = await submitAddress(error => toast.error({
|
||||
title: t('transport.carriers.toast.error'),
|
||||
message: apiErrorMessage(error),
|
||||
}))
|
||||
@@ -469,11 +505,42 @@ async function onSubmitAddresses(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Modal de confirmation de suppression (bloc adresse).
|
||||
// Modal de confirmation de suppression (générique : bloc contact OU prix).
|
||||
const deleteConfirm = reactive({ open: false, action: null as null | (() => void) })
|
||||
|
||||
function askRemoveAddress(index: number): void {
|
||||
deleteConfirm.action = () => { void removeAddress(index) }
|
||||
/** Valide l'onglet Contacts (POST/PATCH par ligne ; avance gérée par le composable). */
|
||||
async function onSubmitContacts(): Promise<void> {
|
||||
const ok = await submitContacts(error => toast.error({
|
||||
title: t('transport.carriers.toast.error'),
|
||||
message: apiErrorMessage(error),
|
||||
}))
|
||||
if (ok) {
|
||||
toast.success({ title: t('transport.carriers.toast.contactSaved') })
|
||||
}
|
||||
}
|
||||
|
||||
function askRemoveContact(index: number): void {
|
||||
deleteConfirm.action = () => { void removeContact(index) }
|
||||
deleteConfirm.open = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Prix = DERNIER onglet du flux de création. Au succès, l'ajout est
|
||||
* terminé : toast final + retour au répertoire transporteurs (aligné sur M1/M2/M3).
|
||||
*/
|
||||
async function onSubmitPrices(): Promise<void> {
|
||||
const ok = await submitPrices(error => toast.error({
|
||||
title: t('transport.carriers.toast.error'),
|
||||
message: apiErrorMessage(error),
|
||||
}))
|
||||
if (ok) {
|
||||
toast.success({ title: t('transport.carriers.toast.priceSaved') })
|
||||
await navigateTo('/carriers')
|
||||
}
|
||||
}
|
||||
|
||||
function askRemovePrice(index: number): void {
|
||||
deleteConfirm.action = () => { void removePrice(index) }
|
||||
deleteConfirm.open = true
|
||||
}
|
||||
|
||||
@@ -483,80 +550,28 @@ function runDeleteConfirm(): void {
|
||||
deleteConfirm.open = false
|
||||
}
|
||||
|
||||
// ── Saisie assistee QUALIMAT (onglet Qualimat) ───────────────────────────────
|
||||
const confirmOpen = ref(false)
|
||||
const pendingRow = ref<QualimatCarrierRow | null>(null)
|
||||
|
||||
// Le datatable QUALIMAT est filtré par le NOM saisi dans le formulaire principal
|
||||
// (RG-4.01) — pas de champ de recherche dédié. Aucune recherche tant que le Nom est
|
||||
// vide (tableau vide par défaut) ; sinon re-filtrage debouncé à chaque frappe.
|
||||
const filterQualimatByName = debounce((term: string) => {
|
||||
if (term.trim() === '') {
|
||||
return
|
||||
}
|
||||
void qualimatSetFilters({ search: term })
|
||||
}, 300)
|
||||
|
||||
watch(() => main.name, term => filterQualimatByName(term))
|
||||
|
||||
/** Adresse QUALIMAT condensee pour la colonne « Adresse » (voie · CP · ville). */
|
||||
function formatQualimatAddress(row: QualimatCarrierRow): string {
|
||||
return [row.address, row.postalCode, row.city].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
/** RG-4.04 : un agrement est perime si sa date de validite est < aujourd'hui. */
|
||||
function isExpired(value: string): boolean {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return false
|
||||
}
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date.getTime() < today.getTime()
|
||||
}
|
||||
|
||||
/** Format court francais JJ-MM-AAAA (chaine vide si date absente / invalide). */
|
||||
function formatDateFr(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
return `${day}-${month}-${date.getFullYear()}`
|
||||
}
|
||||
|
||||
/** Clic sur une ligne du datatable → retrouve la ligne QUALIMAT source + modal. */
|
||||
function onQualimatRowClick(item: Record<string, unknown>): void {
|
||||
const row = qualimatItems.value.find(r => r.id === item.id)
|
||||
if (row) {
|
||||
askIntegrate(row)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ouvre la modal de confirmation d'integration pour une ligne QUALIMAT. */
|
||||
function askIntegrate(row: QualimatCarrierRow): void {
|
||||
pendingRow.value = row
|
||||
confirmOpen.value = true
|
||||
}
|
||||
|
||||
/** Confirme l'integration : copie + PATCH (cf. useCarrierForm.applyQualimatSelection). */
|
||||
async function confirmIntegrate(): Promise<void> {
|
||||
const row = pendingRow.value
|
||||
confirmOpen.value = false
|
||||
if (row === null) {
|
||||
return
|
||||
}
|
||||
/** Intégration d'une ligne QUALIMAT (émise par CarrierQualimatTab) : copie + PATCH
|
||||
* (cf. useCarrierForm.applyQualimatSelection). */
|
||||
async function onIntegrateQualimat(row: QualimatCarrierRow): Promise<void> {
|
||||
const ok = await applyQualimatSelection(row)
|
||||
if (ok) {
|
||||
toast.success({ title: t('transport.carriers.toast.integrateSuccess') })
|
||||
}
|
||||
}
|
||||
|
||||
// Indexation plafonnée à 100 % : la clé force le ré-affichage du MalioInputAmount
|
||||
// (contrôlé) quand le plafonnement laisse le modelValue inchangé.
|
||||
const indexationKey = ref(0)
|
||||
|
||||
/** Saisie de l'indexation : plafonne à 100 et re-synchronise le champ si plafonné. */
|
||||
function onIndexationInput(value: string): void {
|
||||
const clamped = clampPercent(value)
|
||||
main.indexationRate = clamped
|
||||
if (clamped !== value) {
|
||||
indexationKey.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
/** Retour vers le repertoire transporteurs (fleche d'en-tete). */
|
||||
function goBack(): void {
|
||||
router.push('/carriers')
|
||||
@@ -570,7 +585,7 @@ function goBack(): void {
|
||||
async function onSubmitMain(): Promise<void> {
|
||||
const ok = await submitMain()
|
||||
if (ok && isQualimat.value) {
|
||||
await submitAddresses(error => toast.error({
|
||||
await submitAddress(error => toast.error({
|
||||
title: t('transport.carriers.toast.error'),
|
||||
message: apiErrorMessage(error),
|
||||
}))
|
||||
|
||||
@@ -40,7 +40,8 @@ export function emptyCarrierMain(): CarrierMainDraft {
|
||||
certificationType: null,
|
||||
isChartered: false,
|
||||
indexationRate: '',
|
||||
containerType: null,
|
||||
// Défaut métier : Benne pré-sélectionné (radio du formulaire principal).
|
||||
containerType: 'BENNE',
|
||||
volumeM3: '',
|
||||
liotPlates: '',
|
||||
dischargeDocumentIri: null,
|
||||
@@ -94,6 +95,86 @@ export function emptyCarrierAddress(): CarrierAddressFormDraft {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Brouillon d'un bloc Contact (onglet Contacts, ERP-168) — sous-ressource
|
||||
* `CarrierContact` (groupe `carrier:write:contacts`). Les téléphones sont saisis
|
||||
* en `phonePrimary` / `phoneSecondary` côté UI, puis envoyés au back sous forme du
|
||||
* tableau `phones` (max 2 — RG-4.08). `hasSecondaryPhone` pilote l'affichage du 2e
|
||||
* numéro (révélé via le bouton « + »). Pas d'`iri` : aucune relation M2M depuis
|
||||
* l'adresse au M4 (≠ M3).
|
||||
*/
|
||||
export interface CarrierContactFormDraft {
|
||||
/** Id serveur une fois le contact créé (null tant que non persisté). */
|
||||
id: number | null
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
jobTitle: string | null
|
||||
phonePrimary: string | null
|
||||
phoneSecondary: string | null
|
||||
email: string | null
|
||||
/** UI : le 2e numéro a été révélé via le bouton « + » (max 2 téléphones). */
|
||||
hasSecondaryPhone: boolean
|
||||
}
|
||||
|
||||
/** Brouillon de contact vide (état initial d'un bloc Contact). */
|
||||
export function emptyCarrierContact(): CarrierContactFormDraft {
|
||||
return {
|
||||
id: null,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
jobTitle: null,
|
||||
phonePrimary: null,
|
||||
phoneSecondary: null,
|
||||
email: null,
|
||||
hasSecondaryPhone: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Brouillon d'un bloc Prix (onglet Prix, ERP-169 — RG-4.09→4.11). `direction`
|
||||
* pilote la branche active : CLIENT (client + adresse de livraison + site de
|
||||
* départ) ou FOURNISSEUR (fournisseur + adresse d'appro + site de livraison). Les
|
||||
* relations partent au back en IRI (string). `price` est une chaîne (MalioInputAmount).
|
||||
*/
|
||||
export interface CarrierPriceFormDraft {
|
||||
id: number | null
|
||||
direction: 'CLIENT' | 'FOURNISSEUR' | null
|
||||
// Branche CLIENT (RG-4.10).
|
||||
clientIri: string | null
|
||||
clientDeliveryAddressIri: string | null
|
||||
departureSiteIri: string | null
|
||||
// Branche FOURNISSEUR (RG-4.11).
|
||||
supplierIri: string | null
|
||||
supplierSupplyAddressIri: string | null
|
||||
deliverySiteIri: string | null
|
||||
// Communs (toujours requis).
|
||||
containerType: string | null
|
||||
pricingUnit: string | null
|
||||
price: string | null
|
||||
priceState: string | null
|
||||
}
|
||||
|
||||
/** Brouillon de prix vide (état initial d'un bloc Prix : tout masqué tant que direction null). */
|
||||
export function emptyCarrierPrice(): CarrierPriceFormDraft {
|
||||
return {
|
||||
id: null,
|
||||
// Défaut métier : sens CLIENT pré-sélectionné (un bloc prix CLIENT est présent
|
||||
// d'office à l'ouverture de l'onglet).
|
||||
direction: 'CLIENT',
|
||||
clientIri: null,
|
||||
clientDeliveryAddressIri: null,
|
||||
departureSiteIri: null,
|
||||
supplierIri: null,
|
||||
supplierSupplyAddressIri: null,
|
||||
deliverySiteIri: null,
|
||||
// Défauts métier : Benne + Forfait pré-sélectionnés à l'ajout d'un bloc prix.
|
||||
containerType: 'BENNE',
|
||||
pricingUnit: 'FORFAIT',
|
||||
price: null,
|
||||
priceState: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Réponse du POST / PATCH principal (groupe `carrier:read`). Le serveur renvoie
|
||||
* le nom normalisé (UPPERCASE, RG-4.13) que l'UI réaffiche tel quel.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
canEditCarrier,
|
||||
iriOf,
|
||||
labelOfRelation,
|
||||
mapAddressToDraft,
|
||||
mapContactToDraft,
|
||||
mapMainToDraft,
|
||||
mapPriceToDraft,
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
type CarrierDetail,
|
||||
} from '../carrierMappers'
|
||||
|
||||
/**
|
||||
* Tests des mappers détail → brouillons (M4 Transport, ERP-170) : peuplent les écrans
|
||||
* Consultation / Modification depuis la SEULE réponse `GET /api/carriers/{id}`, et
|
||||
* helpers de visibilité des boutons (Modifier / Archiver / Restaurer) selon la permission.
|
||||
*/
|
||||
describe('carrierMappers', () => {
|
||||
it('iriOf : objet embarqué, IRI nu, ou null', () => {
|
||||
expect(iriOf({ '@id': '/api/clients/3' })).toBe('/api/clients/3')
|
||||
expect(iriOf('/api/sites/1')).toBe('/api/sites/1')
|
||||
expect(iriOf(null)).toBeNull()
|
||||
expect(iriOf(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('labelOfRelation : name (site) à défaut adresse condensée', () => {
|
||||
expect(labelOfRelation({ '@id': '/api/sites/1', name: 'Châtellerault' })).toBe('Châtellerault')
|
||||
expect(labelOfRelation({ '@id': '/api/client_addresses/8', street: '1 rue X', postalCode: '86000', city: 'Poitiers' })).toBe('1 rue X · 86000 · Poitiers')
|
||||
expect(labelOfRelation('/api/sites/1')).toBe('')
|
||||
expect(labelOfRelation(null)).toBe('')
|
||||
})
|
||||
|
||||
it('mapMainToDraft : scalaires + IRI décharge / qualimat', () => {
|
||||
const detail: CarrierDetail = {
|
||||
'@id': '/api/carriers/7',
|
||||
id: 7,
|
||||
name: 'TRANSPORTS ACME',
|
||||
certificationType: 'QUALIMAT',
|
||||
isChartered: true,
|
||||
indexationRate: '5.00',
|
||||
containerType: 'BENNE',
|
||||
volumeM3: '30.00',
|
||||
dischargeDocument: { '@id': '/api/uploaded_documents/4' },
|
||||
qualimatCarrier: { '@id': '/api/qualimat_carriers/42' },
|
||||
}
|
||||
expect(mapMainToDraft(detail)).toEqual({
|
||||
name: 'TRANSPORTS ACME',
|
||||
certificationType: 'QUALIMAT',
|
||||
isChartered: true,
|
||||
indexationRate: '5.00',
|
||||
containerType: 'BENNE',
|
||||
volumeM3: '30.00',
|
||||
liotPlates: '',
|
||||
dischargeDocumentIri: '/api/uploaded_documents/4',
|
||||
qualimatCarrierIri: '/api/qualimat_carriers/42',
|
||||
})
|
||||
})
|
||||
|
||||
it('mapAddressToDraft : pays par défaut France si absent', () => {
|
||||
expect(mapAddressToDraft({ '@id': '/api/carrier_addresses/3', id: 3, postalCode: '86000', city: 'Poitiers' }))
|
||||
.toEqual({ id: 3, country: 'France', postalCode: '86000', city: 'Poitiers', street: null, streetComplement: null })
|
||||
})
|
||||
|
||||
it('mapContactToDraft : hasSecondaryPhone vrai seulement si 2e numéro présent', () => {
|
||||
const one = mapContactToDraft({ '@id': '/api/carrier_contacts/1', id: 1, firstName: 'Jean', phonePrimary: '0102030405' })
|
||||
expect(one.hasSecondaryPhone).toBe(false)
|
||||
expect(one.firstName).toBe('Jean')
|
||||
|
||||
const two = mapContactToDraft({ '@id': '/api/carrier_contacts/2', id: 2, phonePrimary: '0102030405', phoneSecondary: '0605040302' })
|
||||
expect(two.hasSecondaryPhone).toBe(true)
|
||||
expect(two.phoneSecondary).toBeTruthy()
|
||||
})
|
||||
|
||||
it('mapPriceToDraft : direction + IRIs des relations de branche', () => {
|
||||
const draft = mapPriceToDraft({
|
||||
'@id': '/api/carrier_prices/5',
|
||||
id: 5,
|
||||
direction: 'CLIENT',
|
||||
client: { '@id': '/api/clients/3' },
|
||||
clientDeliveryAddress: { '@id': '/api/client_addresses/8' },
|
||||
departureSite: '/api/sites/1',
|
||||
containerType: 'BENNE',
|
||||
pricingUnit: 'FORFAIT',
|
||||
price: '120.00',
|
||||
priceState: 'EN_COURS',
|
||||
})
|
||||
expect(draft).toMatchObject({
|
||||
id: 5,
|
||||
direction: 'CLIENT',
|
||||
clientIri: '/api/clients/3',
|
||||
clientDeliveryAddressIri: '/api/client_addresses/8',
|
||||
departureSiteIri: '/api/sites/1',
|
||||
supplierIri: null,
|
||||
containerType: 'BENNE',
|
||||
pricingUnit: 'FORFAIT',
|
||||
price: '120.00',
|
||||
priceState: 'EN_COURS',
|
||||
})
|
||||
})
|
||||
|
||||
it('visibilité des boutons selon la permission', () => {
|
||||
const can = (granted: string[]) => (code: string) => granted.includes(code)
|
||||
|
||||
// Modifier : seulement avec manage.
|
||||
expect(canEditCarrier(can(['transport.carriers.manage']))).toBe(true)
|
||||
expect(canEditCarrier(can(['transport.carriers.view']))).toBe(false)
|
||||
|
||||
// Archiver : permission archive ET actif ; Restaurer : archive ET archivé.
|
||||
const withArchive = can(['transport.carriers.archive'])
|
||||
const noArchive = can(['transport.carriers.manage'])
|
||||
expect(showArchiveAction(withArchive, false)).toBe(true)
|
||||
expect(showArchiveAction(withArchive, true)).toBe(false)
|
||||
expect(showRestoreAction(withArchive, true)).toBe(true)
|
||||
expect(showRestoreAction(withArchive, false)).toBe(false)
|
||||
expect(showArchiveAction(noArchive, false)).toBe(false)
|
||||
expect(showRestoreAction(noArchive, true)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { clampPercent, sanitizeDecimal } from '../numberInput'
|
||||
|
||||
describe('numberInput — saisie volume / indexation (ERP-170)', () => {
|
||||
it('sanitizeDecimal : ne garde que chiffres + un seul point', () => {
|
||||
expect(sanitizeDecimal('30')).toBe('30')
|
||||
expect(sanitizeDecimal('30.5')).toBe('30.5')
|
||||
expect(sanitizeDecimal('30,5 kg')).toBe('30.5') // virgule FR → point ; espace + lettres retirés
|
||||
expect(sanitizeDecimal('1.2.3')).toBe('1.23') // un seul point conservé
|
||||
expect(sanitizeDecimal('abc12.3x')).toBe('12.3')
|
||||
expect(sanitizeDecimal('')).toBe('')
|
||||
})
|
||||
|
||||
it('clampPercent : plafonne à 100, laisse le reste tel quel', () => {
|
||||
expect(clampPercent('50')).toBe('50')
|
||||
expect(clampPercent('100')).toBe('100')
|
||||
expect(clampPercent('150')).toBe('100')
|
||||
expect(clampPercent('100.01')).toBe('100')
|
||||
expect(clampPercent('12,5')).toBe('12,5') // ≤ 100 → inchangé
|
||||
expect(clampPercent('')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -8,18 +8,7 @@
|
||||
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
|
||||
|
||||
/**
|
||||
* RG-4.05 : gate du bouton « + Nouvelle adresse ». Une adresse est « complète »
|
||||
* (donc on autorise l'ajout d'un nouveau bloc) dès qu'elle porte un code postal,
|
||||
* une ville ET une rue. Les RG conditionnelles (obligatoire si affrété) restent
|
||||
* validées par le back (422 inline) — ce gate empêche seulement d'empiler des
|
||||
* blocs vides.
|
||||
*/
|
||||
export function isCarrierAddressValid(address: CarrierAddressFormDraft): boolean {
|
||||
return Boolean(address.postalCode?.trim() && address.city?.trim() && address.street?.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload de la sous-ressource addresses (groupe `carrier:write:addresses`). Les
|
||||
* Payload de la sous-ressource address (groupe `carrier:write:addresses`). Les
|
||||
* scalaires sont nullable côté entité : on envoie `null` quand le champ est vide
|
||||
* (le `CarrierAddressProcessor` re-valide la présence si affrété — RG-4.05 — et
|
||||
* renvoie une 422 par champ).
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Helpers purs de l'onglet Contact transporteur (M4 Transport, ERP-168) — ALIGNÉ
|
||||
* sur `providerContact.ts` (M3) / les autres modules : mêmes règles de validité et
|
||||
* de gating « + Nouveau contact » (un contact est « nommé » dès qu'il porte un
|
||||
* prénom OU un nom). Seule spécificité M4 conservée : les téléphones partent au back
|
||||
* dans le tableau virtuel `phones` (max 2), mappés par le CarrierContactProcessor.
|
||||
* Testables sans Vue ni API.
|
||||
*/
|
||||
|
||||
import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm'
|
||||
|
||||
/** Vrai si une chaîne porte au moins un caractère non-espace. */
|
||||
function isFilled(value: string | null | undefined): boolean {
|
||||
return value !== null && value !== undefined && value.trim() !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Un bloc Contact est VIDE tant qu'aucun champ comptant pour la validité n'est
|
||||
* rempli — prénom / nom / fonction / téléphone principal / email. `phoneSecondary`
|
||||
* est EXCLU (aligné M1/M2/M3 : un bloc ne portant qu'un 2e numéro reste vide). Sert
|
||||
* le filtrage des amorces vides à la soumission.
|
||||
*/
|
||||
export function isCarrierContactBlank(contact: CarrierContactFormDraft): boolean {
|
||||
return ![
|
||||
contact.firstName,
|
||||
contact.lastName,
|
||||
contact.jobTitle,
|
||||
contact.phonePrimary,
|
||||
contact.email,
|
||||
].some(isFilled)
|
||||
}
|
||||
|
||||
/**
|
||||
* Un contact est « nommé » (valide) dès qu'il porte un prénom OU un nom — aligné
|
||||
* sur M1/M2/M3. Pilote le gating « + Nouveau contact » : la fonction / le téléphone
|
||||
* / l'email seuls ne suffisent pas pour ajouter un nouveau bloc.
|
||||
*/
|
||||
export function isCarrierContactNamed(contact: CarrierContactFormDraft): boolean {
|
||||
return isFilled(contact.firstName) || isFilled(contact.lastName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload de la sous-ressource contacts (groupe `carrier:write:contacts`). Les
|
||||
* chaînes vides partent à null (le serveur normalise/trim). Les téléphones sont
|
||||
* regroupés dans le tableau `phones` (numéros non vides, max 2 — RG-4.08) ; le 2e
|
||||
* numéro n'est inclus que s'il a été révélé (`hasSecondaryPhone`).
|
||||
*/
|
||||
export function buildCarrierContactPayload(contact: CarrierContactFormDraft): Record<string, unknown> {
|
||||
const phones = [
|
||||
contact.phonePrimary,
|
||||
contact.hasSecondaryPhone ? contact.phoneSecondary : null,
|
||||
].filter((phone): phone is string => isFilled(phone))
|
||||
|
||||
return {
|
||||
firstName: contact.firstName || null,
|
||||
lastName: contact.lastName || null,
|
||||
jobTitle: contact.jobTitle || null,
|
||||
email: contact.email || null,
|
||||
phones,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Helpers purs des écrans Consultation / Modification transporteur (M4, ERP-170) —
|
||||
* miroir de `providerDetail.ts` (M3). Mappent le payload `GET /api/carriers/{id}`
|
||||
* (relations embarquées via les groupes `carrier:item:read` + `qualimat:read` +
|
||||
* read-groups cross-module client/supplier/site/adresses) vers les brouillons
|
||||
* « plats » partagés avec les blocs Adresse / Contact / Prix.
|
||||
*
|
||||
* Ne touchent ni à l'API ni à l'état réactif (testables unitairement). Les champs
|
||||
* nuls peuvent être OMIS (skip_null_values) → toujours lire avec `?? null`.
|
||||
*/
|
||||
|
||||
import { formatPhoneFR } from '~/shared/utils/phone'
|
||||
import type {
|
||||
CarrierAddressFormDraft,
|
||||
CarrierContactFormDraft,
|
||||
CarrierMainDraft,
|
||||
CarrierPriceFormDraft,
|
||||
} from '~/modules/transport/types/carrierForm'
|
||||
|
||||
/** Référence Hydra embarquée minimale (@id toujours présent). */
|
||||
export interface HydraRef {
|
||||
'@id': string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Une relation peut être embarquée (objet), un IRI nu (chaîne) ou absente. */
|
||||
export type Relation = HydraRef | string | null | undefined
|
||||
|
||||
/** Adresse embarquée (groupe carrier:item:read). */
|
||||
export interface CarrierAddressRead extends HydraRef {
|
||||
id: number
|
||||
country?: string | null
|
||||
postalCode?: string | null
|
||||
city?: string | null
|
||||
street?: string | null
|
||||
streetComplement?: string | null
|
||||
}
|
||||
|
||||
/** Contact embarqué (groupe carrier:item:read). */
|
||||
export interface CarrierContactRead extends HydraRef {
|
||||
id: number
|
||||
firstName?: string | null
|
||||
lastName?: string | null
|
||||
jobTitle?: string | null
|
||||
phonePrimary?: string | null
|
||||
phoneSecondary?: string | null
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
/** Prix embarqué (groupe carrier:item:read + relations cross-module). */
|
||||
export interface CarrierPriceRead extends HydraRef {
|
||||
id: number
|
||||
direction?: string | null
|
||||
client?: Relation
|
||||
clientDeliveryAddress?: Relation
|
||||
departureSite?: Relation
|
||||
supplier?: Relation
|
||||
supplierSupplyAddress?: Relation
|
||||
deliverySite?: Relation
|
||||
containerType?: string | null
|
||||
pricingUnit?: string | null
|
||||
price?: string | null
|
||||
priceState?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Détail d'un transporteur (`GET /api/carriers/{id}`). Tous les champs optionnels :
|
||||
* skip_null_values peut omettre n'importe quelle clé.
|
||||
*/
|
||||
export interface CarrierDetail extends HydraRef {
|
||||
id: number
|
||||
name?: string | null
|
||||
certificationType?: string | null
|
||||
isChartered?: boolean
|
||||
indexationRate?: string | null
|
||||
containerType?: string | null
|
||||
volumeM3?: string | null
|
||||
liotPlates?: string | null
|
||||
dischargeDocument?: Relation
|
||||
qualimatCarrier?: Relation
|
||||
isArchived?: boolean
|
||||
// Adresse UNIQUE (OneToOne, ERP-172) : objet embarqué (ou absent), pas une liste.
|
||||
address?: CarrierAddressRead | null
|
||||
contacts?: CarrierContactRead[]
|
||||
prices?: CarrierPriceRead[]
|
||||
}
|
||||
|
||||
/** Extrait l'IRI d'une relation (objet embarqué, IRI nu, ou null si absente). */
|
||||
export function iriOf(relation: Relation): string | null {
|
||||
if (relation === null || relation === undefined) {
|
||||
return null
|
||||
}
|
||||
if (typeof relation === 'string') {
|
||||
return relation
|
||||
}
|
||||
return relation['@id'] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Libellé d'affichage d'une relation embarquée : `name` (site) à défaut une adresse
|
||||
* condensée (voie · CP · ville). Chaîne vide si la relation est un IRI nu / absente.
|
||||
*/
|
||||
export function labelOfRelation(relation: Relation): string {
|
||||
if (!relation || typeof relation === 'string') {
|
||||
return ''
|
||||
}
|
||||
const name = relation.name as string | undefined
|
||||
if (name) {
|
||||
return name
|
||||
}
|
||||
const parts = [relation.street, relation.postalCode, relation.city].filter(Boolean)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/** Mappe le détail vers le brouillon du formulaire principal. */
|
||||
export function mapMainToDraft(detail: CarrierDetail): CarrierMainDraft {
|
||||
return {
|
||||
name: detail.name ?? '',
|
||||
certificationType: detail.certificationType ?? null,
|
||||
isChartered: detail.isChartered ?? false,
|
||||
indexationRate: detail.indexationRate ?? '',
|
||||
containerType: detail.containerType ?? null,
|
||||
volumeM3: detail.volumeM3 ?? '',
|
||||
liotPlates: detail.liotPlates ?? '',
|
||||
dischargeDocumentIri: iriOf(detail.dischargeDocument),
|
||||
qualimatCarrierIri: iriOf(detail.qualimatCarrier),
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe une adresse embarquée vers un brouillon. */
|
||||
export function mapAddressToDraft(address: CarrierAddressRead): CarrierAddressFormDraft {
|
||||
return {
|
||||
id: address.id,
|
||||
country: address.country ?? 'France',
|
||||
postalCode: address.postalCode ?? null,
|
||||
city: address.city ?? null,
|
||||
street: address.street ?? null,
|
||||
streetComplement: address.streetComplement ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe un contact embarqué vers un brouillon (téléphones formatés XX XX XX XX XX). */
|
||||
export function mapContactToDraft(contact: CarrierContactRead): CarrierContactFormDraft {
|
||||
const secondary = contact.phoneSecondary ?? null
|
||||
return {
|
||||
id: contact.id,
|
||||
firstName: contact.firstName ?? null,
|
||||
lastName: contact.lastName ?? null,
|
||||
jobTitle: contact.jobTitle ?? null,
|
||||
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
|
||||
phoneSecondary: secondary ? formatPhoneFR(secondary) : null,
|
||||
email: contact.email ?? null,
|
||||
hasSecondaryPhone: secondary !== null && secondary !== '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe un prix embarqué vers un brouillon (relations en IRI). */
|
||||
export function mapPriceToDraft(price: CarrierPriceRead): CarrierPriceFormDraft {
|
||||
const direction = price.direction === 'CLIENT' || price.direction === 'FOURNISSEUR'
|
||||
? price.direction
|
||||
: null
|
||||
return {
|
||||
id: price.id,
|
||||
direction,
|
||||
clientIri: iriOf(price.client),
|
||||
clientDeliveryAddressIri: iriOf(price.clientDeliveryAddress),
|
||||
departureSiteIri: iriOf(price.departureSite),
|
||||
supplierIri: iriOf(price.supplier),
|
||||
supplierSupplyAddressIri: iriOf(price.supplierSupplyAddress),
|
||||
deliverySiteIri: iriOf(price.deliverySite),
|
||||
containerType: price.containerType ?? null,
|
||||
pricingUnit: price.pricingUnit ?? null,
|
||||
price: price.price ?? null,
|
||||
priceState: price.priceState ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Bouton « Modifier » : visible avec la permission `manage` (Admin / Bureau). */
|
||||
export function canEditCarrier(can: (code: string) => boolean): boolean {
|
||||
return can('transport.carriers.manage')
|
||||
}
|
||||
|
||||
/** Bouton « Archiver » : permission archive ET transporteur encore actif (Admin seul). */
|
||||
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
||||
return can('transport.carriers.archive') && !isArchived
|
||||
}
|
||||
|
||||
/** Bouton « Restaurer » : permission archive ET transporteur déjà archivé (Admin seul). */
|
||||
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
||||
return can('transport.carriers.archive') && isArchived
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Helpers purs de l'onglet Prix transporteur (M4 Transport, ERP-169 — RG-4.09→4.11).
|
||||
* Une ligne porte une branche CLIENT ou FOURNISSEUR selon `direction` ; les champs
|
||||
* de la branche INACTIVE doivent toujours partir à null (CHECK BDD
|
||||
* chk_carrier_price_client_branch / supplier_branch). Testables sans Vue ni API.
|
||||
*/
|
||||
|
||||
import type { CarrierPriceFormDraft } from '~/modules/transport/types/carrierForm'
|
||||
|
||||
/** Vrai si une chaîne porte au moins un caractère non-espace. */
|
||||
function isFilled(value: string | null | undefined): boolean {
|
||||
return value !== null && value !== undefined && value.trim() !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload de la sous-ressource prix (groupe `carrier:write:prices`). Envoie les
|
||||
* communs + UNIQUEMENT la branche active (l'autre branche à null, exigée par les
|
||||
* CHECK BDD). Les relations partent en IRI (string|null).
|
||||
*
|
||||
* IMPORTANT : les scalaires obligatoires (direction / containerType / pricingUnit /
|
||||
* price / priceState) sont OMIS s'ils sont vides — on n'envoie JAMAIS `null` sur un
|
||||
* champ string. Sinon API Platform lève un 400 « The type of the "price" attribute
|
||||
* must be "string", "NULL" given. » AVANT la validation (non mappable inline). Omis,
|
||||
* le champ reste null côté entité → l'Assert\NotBlank renvoie un 422 propre rattaché
|
||||
* au champ, affiché sous l'input comme les autres blocs (ERP-101). Le back re-valide
|
||||
* aussi l'obligation conditionnelle de branche + l'appartenance de l'adresse.
|
||||
*/
|
||||
export function buildCarrierPricePayload(price: CarrierPriceFormDraft): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {}
|
||||
|
||||
// Scalaires : présents seulement si remplis (jamais `null` → évite le 400 de type).
|
||||
if (isFilled(price.direction)) payload.direction = price.direction
|
||||
if (isFilled(price.containerType)) payload.containerType = price.containerType
|
||||
if (isFilled(price.pricingUnit)) payload.pricingUnit = price.pricingUnit
|
||||
if (isFilled(price.price)) payload.price = price.price
|
||||
if (isFilled(price.priceState)) payload.priceState = price.priceState
|
||||
|
||||
// Branche active en IRI (null toléré sur une relation, ne déclenche pas le 400 de
|
||||
// type) ; branche inactive forcée à null (CHECK BDD chk_carrier_price_*_branch).
|
||||
if (price.direction === 'CLIENT') {
|
||||
payload.client = price.clientIri || null
|
||||
payload.clientDeliveryAddress = price.clientDeliveryAddressIri || null
|
||||
payload.departureSite = price.departureSiteIri || null
|
||||
payload.supplier = null
|
||||
payload.supplierSupplyAddress = null
|
||||
payload.deliverySite = null
|
||||
}
|
||||
else if (price.direction === 'FOURNISSEUR') {
|
||||
payload.supplier = price.supplierIri || null
|
||||
payload.supplierSupplyAddress = price.supplierSupplyAddressIri || null
|
||||
payload.deliverySite = price.deliverySiteIri || null
|
||||
payload.client = null
|
||||
payload.clientDeliveryAddress = null
|
||||
payload.departureSite = null
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
/**
|
||||
* Pré-check léger du gating « + Nouveau prix » : direction choisie, prix rempli, et
|
||||
* branche active complète (client/adresse/site OU fournisseur/adresse/site). Le back
|
||||
* reste la couche autoritaire (RG-4.09→4.11) ; ce pré-check évite d'empiler des
|
||||
* blocs vides.
|
||||
*/
|
||||
export function isCarrierPriceValid(price: CarrierPriceFormDraft): boolean {
|
||||
if (!isFilled(price.price) || !isFilled(price.containerType) || !isFilled(price.pricingUnit) || !isFilled(price.priceState)) {
|
||||
return false
|
||||
}
|
||||
if (price.direction === 'CLIENT') {
|
||||
return isFilled(price.clientIri) && isFilled(price.clientDeliveryAddressIri) && isFilled(price.departureSiteIri)
|
||||
}
|
||||
if (price.direction === 'FOURNISSEUR') {
|
||||
return isFilled(price.supplierIri) && isFilled(price.supplierSupplyAddressIri) && isFilled(price.deliverySiteIri)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Helpers de saisie numérique du formulaire principal transporteur (ERP-170).
|
||||
* Champs texte restreints (volume m³ décimal, indexation plafonnée). Purs / testables.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Restreint une saisie à un nombre décimal : chiffres + UN seul point (RG volume m³,
|
||||
* « nombres avec des points » comme les autres modules). La virgule décimale FR est
|
||||
* convertie en point (« 30,5 » → « 30.5 ») ; tout autre caractère est supprimé.
|
||||
*/
|
||||
export function sanitizeDecimal(value: string): string {
|
||||
let cleaned = (value ?? '').replace(/,/g, '.').replace(/[^0-9.]/g, '')
|
||||
const dot = cleaned.indexOf('.')
|
||||
if (dot !== -1) {
|
||||
// Conserve le 1er point, retire les suivants.
|
||||
cleaned = cleaned.slice(0, dot + 1) + cleaned.slice(dot + 1).replace(/\./g, '')
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
/**
|
||||
* Plafonne un pourcentage à 100 (contrainte FRONT : l'indexation n'a pas de max back).
|
||||
* Renvoie « 100 » si la valeur saisie dépasse 100, sinon la valeur telle quelle.
|
||||
*/
|
||||
export function clampPercent(value: string): string {
|
||||
const n = Number(String(value ?? '').replace(',', '.').replace(/\s/g, ''))
|
||||
return (!Number.isNaN(n) && n > 100) ? '100' : value
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Tests du composable d'upload générique (ERP-171) :
|
||||
* - succès : POST multipart /uploaded_documents (champ « file »), toast désactivé,
|
||||
* renvoie l'IRI (@id) du document créé, `uploading` retombe à false ;
|
||||
* - erreur MIME hors whitelist → 422 : l'erreur est RELAYÉE à l'appelant (pour un
|
||||
* affichage inline sous le champ), `uploading` ré-armé via le finally.
|
||||
*/
|
||||
|
||||
const mockPost = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useApi', () => ({
|
||||
get: vi.fn(),
|
||||
post: mockPost,
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
|
||||
const { useUpload } = await import('../useUpload')
|
||||
|
||||
describe('useUpload', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
})
|
||||
|
||||
it('succès : POST multipart champ « file » + toast:false → renvoie l\'IRI', async () => {
|
||||
mockPost.mockResolvedValue({ '@id': '/api/uploaded_documents/9', originalFilename: 'decharge.pdf' })
|
||||
const { upload, uploading } = useUpload()
|
||||
const file = new File(['contenu'], 'decharge.pdf', { type: 'application/pdf' })
|
||||
|
||||
const iri = await upload(file)
|
||||
|
||||
expect(iri).toBe('/api/uploaded_documents/9')
|
||||
|
||||
const [url, body, options] = mockPost.mock.calls[0]
|
||||
expect(url).toBe('/uploaded_documents')
|
||||
expect(body).toBeInstanceOf(FormData)
|
||||
const stored = (body as FormData).get('file')
|
||||
expect(stored).toBeInstanceOf(File)
|
||||
expect((stored as File).name).toBe('decharge.pdf')
|
||||
expect(options).toMatchObject({ toast: false })
|
||||
|
||||
expect(uploading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('erreur MIME → 422 : l\'erreur est remontée à l\'appelant', async () => {
|
||||
const error = Object.assign(new Error('422'), {
|
||||
data: { 'hydra:description': 'Type de fichier non autorisé.' },
|
||||
})
|
||||
mockPost.mockRejectedValue(error)
|
||||
const { upload, uploading } = useUpload()
|
||||
const file = new File(['x'], 'malware.exe', { type: 'application/x-msdownload' })
|
||||
|
||||
await expect(upload(file)).rejects.toBe(error)
|
||||
expect(uploading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ref } from 'vue'
|
||||
import type { AnyObject } from '~/shared/composables/useApi'
|
||||
|
||||
/**
|
||||
* Réponse JSON-LD de POST /api/uploaded_documents (groupe `uploaded_document:read`).
|
||||
* Seul l'IRI (`@id`) est exploité pour le poser sur la relation cible.
|
||||
*/
|
||||
export interface UploadedDocumentResponse {
|
||||
'@id': string
|
||||
originalFilename?: string
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload d'un document générique vers l'infra partagée (ERP-154) :
|
||||
* POST /api/uploaded_documents en multipart/form-data, champ « file », via
|
||||
* `useApi()` (cookie JWT, parsing Hydra/erreurs). Renvoie l'IRI du document créé,
|
||||
* à poser sur la relation cible (ex: `carrier.dischargeDocument` — RG-4.02).
|
||||
*
|
||||
* Les erreurs (MIME hors whitelist / fichier trop volumineux → 422) sont relayées
|
||||
* (rethrow) à l'appelant pour un affichage inline sous le champ. `toast: false` par
|
||||
* défaut : pas de toast fourre-tout, le formulaire mappe le message au bon champ.
|
||||
*/
|
||||
export function useUpload() {
|
||||
// Indicateur d'upload en cours (désactivation UI / spinner éventuel).
|
||||
const uploading = ref(false)
|
||||
|
||||
/**
|
||||
* Envoie `file` et renvoie l'IRI du `UploadedDocument` créé.
|
||||
* @throws relaie l'erreur réseau / 422 (MIME, taille) à l'appelant.
|
||||
*/
|
||||
async function upload(file: File, options: { toast?: boolean } = {}): Promise<string> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
// useApi() détecte le FormData et n'impose pas de Content-Type JSON :
|
||||
// le navigateur pose lui-même la frontière multipart.
|
||||
const doc = await useApi().post<UploadedDocumentResponse>(
|
||||
'/uploaded_documents',
|
||||
formData as unknown as AnyObject,
|
||||
{ toast: options.toast ?? false },
|
||||
)
|
||||
|
||||
return doc['@id']
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { uploading, upload }
|
||||
}
|
||||
@@ -103,6 +103,12 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
'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'],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* RG-4.08 (correctif) — aligne la regle de validite d'un contact transporteur sur
|
||||
* le M1/M2/M3 : au moins le PRENOM OU le NOM (et non plus « un champ quelconque
|
||||
* parmi prenom/nom/fonction/telephone/email »). Remplace le CHECK
|
||||
* chk_carrier_contact_filled par chk_carrier_contact_name et met a jour les
|
||||
* commentaires de colonnes. La garde applicative (CarrierContactProcessor::validateName)
|
||||
* est alignee dans le meme commit ; le catalogue ColumnCommentsCatalog aussi.
|
||||
*
|
||||
* Placee au namespace racine DoctrineMigrations (et non en modulaire Transport) :
|
||||
* elle ALTERE une table creee par une migration racine (Version20260615150000) ;
|
||||
* le tri par version au sein du meme namespace garantit qu'elle joue APRES l'init
|
||||
* (cf. CLAUDE.md regle 11 — le tri cross-namespace casserait l'ordre sur base vide).
|
||||
*/
|
||||
final class Version20260617120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'RG-4.08 : contact transporteur valide si prenom OU nom (alignement M1/M2/M3) — CHECK chk_carrier_contact_name.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE carrier_contact DROP CONSTRAINT chk_carrier_contact_filled');
|
||||
$this->addSql('ALTER TABLE carrier_contact ADD CONSTRAINT chk_carrier_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)');
|
||||
|
||||
$this->addSql('COMMENT ON TABLE carrier_contact IS $_$Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins le prenom OU le nom rempli (RG-4.08, chk_carrier_contact_name), max 2 telephones.$_$');
|
||||
$this->addSql('COMMENT ON COLUMN carrier_contact.first_name IS $_$Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN carrier_contact.last_name IS $_$Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).$_$');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE carrier_contact DROP CONSTRAINT chk_carrier_contact_name');
|
||||
$this->addSql('ALTER TABLE carrier_contact ADD 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)');
|
||||
|
||||
$this->addSql('COMMENT ON TABLE carrier_contact IS $_$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->addSql('COMMENT ON COLUMN carrier_contact.first_name IS $_$Prenom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN carrier_contact.last_name IS $_$Nom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).$_$');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-172 — adresse UNIQUE par transporteur (decision metier : un transporteur a
|
||||
* au plus une adresse). Bascule la relation carrier_address.carrier_id en OneToOne :
|
||||
* remplace l'index simple idx_carrier_address_carrier par une contrainte d'unicite
|
||||
* uniq_carrier_address_carrier. La garde applicative (CarrierAddressProcessor) renvoie
|
||||
* un 409 explicite avant d'atteindre cette contrainte.
|
||||
*
|
||||
* Placee au namespace racine DoctrineMigrations (et non en modulaire Transport) :
|
||||
* elle ALTERE une table creee par une migration racine (Version20260615150000) ;
|
||||
* le tri par version au sein du meme namespace garantit qu'elle joue APRES l'init
|
||||
* (cf. CLAUDE.md regle 11 — le tri cross-namespace casserait l'ordre sur base vide).
|
||||
*/
|
||||
final class Version20260617140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-172 : adresse unique par transporteur — index unique sur carrier_address.carrier_id (OneToOne).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX idx_carrier_address_carrier');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_carrier_address_carrier ON carrier_address (carrier_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX uniq_carrier_address_carrier');
|
||||
$this->addSql('CREATE INDEX idx_carrier_address_carrier ON carrier_address (carrier_id)');
|
||||
}
|
||||
}
|
||||
@@ -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,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-172 — nettoyage des reliquats du multi-adresses sur carrier_address, suite a
|
||||
* la bascule en adresse UNIQUE (Version20260617140000). La colonne `position`
|
||||
* servait a ordonner une LISTE d'adresses ; avec une seule adresse par transporteur
|
||||
* (OneToOne) elle n'a plus de sens -> on la supprime. On reactualise aussi le
|
||||
* COMMENT ON TABLE qui annoncait encore une relation 1:n.
|
||||
*
|
||||
* Placee au namespace racine DoctrineMigrations (et non en modulaire Transport) :
|
||||
* elle ALTERE une table creee par une migration racine (Version20260615150000) ;
|
||||
* le tri par version au sein du meme namespace garantit qu'elle joue APRES l'init
|
||||
* et apres la bascule OneToOne (cf. CLAUDE.md regle 11).
|
||||
*/
|
||||
final class Version20260617160000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-172 : retire la colonne carrier_address.position (relique multi-adresses) + COMMENT ON TABLE 1:1.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE carrier_address DROP COLUMN position');
|
||||
$this->addSql("COMMENT ON TABLE carrier_address IS 'Adresse d un transporteur (1:1, OneToOne — ERP-172 : adresse UNIQUE) — onglet Adresse (M4). Pre-remplie depuis QUALIMAT si applicable (RG-4.05).'");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE carrier_address ADD COLUMN position INT DEFAULT 0 NOT NULL');
|
||||
$this->addSql("COMMENT ON COLUMN carrier_address.position IS 'Ordre d affichage de l adresse dans la liste du transporteur (croissant).'");
|
||||
$this->addSql("COMMENT ON TABLE carrier_address IS 'Adresses d un transporteur (1:n) — onglet Adresse (M4). Pre-remplie depuis QUALIMAT si applicable (RG-4.05).'");
|
||||
}
|
||||
}
|
||||
@@ -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 Version20260617170000 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');
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,9 @@ final class RbacSeeder
|
||||
// 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).
|
||||
@@ -137,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',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -217,6 +217,10 @@ final class SeedE2ECommand extends Command
|
||||
'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,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Application\Service;
|
||||
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
|
||||
/**
|
||||
* Allocateur du compteur DSD du pont bascule (RG-5.04, § 2.7).
|
||||
*
|
||||
* Le DSD est un index sequentiel de pesee, propre a CHAQUE site (un pont par
|
||||
* site). Chaque pesee — bascule (AUTO) ou manuelle (MANUAL) — consomme une
|
||||
* valeur : la suivante = dernier DSD du site + 1.
|
||||
*
|
||||
* Port (interface en couche Application) ; l'implementation (DsdAllocator,
|
||||
* Infrastructure) incremente le compteur sous verrou ligne `SELECT ... FOR
|
||||
* UPDATE` pour garantir l'unicite en concurrence.
|
||||
*/
|
||||
interface DsdAllocatorInterface
|
||||
{
|
||||
/**
|
||||
* Attribue et renvoie la prochaine valeur DSD pour le site (dernier + 1),
|
||||
* en persistant l'increment de maniere atomique (verrou ligne).
|
||||
*/
|
||||
public function next(SiteInterface $site): int;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Application\Service;
|
||||
|
||||
use App\Module\Logistique\Domain\Exception\InvalidImmatriculationException;
|
||||
|
||||
/**
|
||||
* Normalisation serveur des champs texte d'un WeighingTicket, appliquee par le
|
||||
* WeighingTicketProcessor AVANT persistance. Cf. spec-back M5 § 6 + RG-5.01 /
|
||||
* RG-5.10. Jumeau leger de CarrierFieldNormalizer (M4).
|
||||
*
|
||||
* - immatriculation (RG-5.01 / RG-5.10) : trim + UPPER. Si « Tout format » N'EST
|
||||
* PAS coche (freeFormat = false), la saisie est ramenee au masque SIV
|
||||
* canonique XX-000-XX (separateurs/espaces ignores a la saisie, re-poses) ; une
|
||||
* plaque qui ne s'y conforme pas leve InvalidImmatriculationException (-> 422
|
||||
* par le Processor). En « Tout format » (anciennes plaques, etranger, engins),
|
||||
* seul le trim + UPPER s'applique.
|
||||
* - otherLabel (RG-5.03) : trim ; une chaine vide apres trim devient null (evite
|
||||
* de persister "" dans une colonne nullable).
|
||||
*
|
||||
* Methodes null-safe : une entree null ressort null (l'obligation eventuelle est
|
||||
* portee par les Assert de l'entite / la coherence contrepartie, pas ici).
|
||||
*/
|
||||
final class WeighingTicketFieldNormalizer
|
||||
{
|
||||
/**
|
||||
* Plaque SIV « nue » (sans separateurs) : 2 lettres, 3 chiffres, 2 lettres.
|
||||
* Les lettres interdites du SIV (I, O, U + SS) ne sont pas filtrees ici : le
|
||||
* masque de saisie reste volontairement simple (le metier accepte ces cas via
|
||||
* « Tout format » si besoin).
|
||||
*/
|
||||
private const string SIV_BARE_PATTERN = '/^[A-Z]{2}[0-9]{3}[A-Z]{2}$/';
|
||||
|
||||
/**
|
||||
* Normalise l'immatriculation (RG-5.01 / RG-5.10).
|
||||
*
|
||||
* @param bool $freeFormat « Tout format » coche -> masque SIV desactive
|
||||
*
|
||||
* @throws InvalidImmatriculationException si !freeFormat et la plaque ne
|
||||
* respecte pas le masque XX-000-XX
|
||||
*/
|
||||
public function normalizeImmatriculation(?string $value, bool $freeFormat): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = mb_strtoupper(trim($value), 'UTF-8');
|
||||
if ('' === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// « Tout format » : aucune contrainte de masque (RG-5.01).
|
||||
if ($freeFormat) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Masque SIV : on ignore tout ce qui n'est pas alphanumerique (l'operateur
|
||||
// peut saisir « ab123cd », « AB 123 CD » ou « AB-123-CD ») puis on valide
|
||||
// le squelette 2-3-2 et on repose les separateurs canoniques.
|
||||
$bare = preg_replace('/[^A-Z0-9]/', '', $value) ?? '';
|
||||
|
||||
if (1 !== preg_match(self::SIV_BARE_PATTERN, $bare)) {
|
||||
throw new InvalidImmatriculationException(
|
||||
'Format d\'immatriculation invalide : attendu XX-000-XX (cochez « Tout format » pour une plaque libre).',
|
||||
);
|
||||
}
|
||||
|
||||
return sprintf('%s-%s-%s', substr($bare, 0, 2), substr($bare, 2, 3), substr($bare, 5, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim du libelle « Autre » (RG-5.03). Une chaine vide apres trim devient null.
|
||||
*/
|
||||
public function normalizeOtherLabel(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Application\Service;
|
||||
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
|
||||
/**
|
||||
* Allocateur du numero de ticket de pesee (RG-5.02, § 2.5).
|
||||
*
|
||||
* Le numero a le format {siteCode}-TP-{NNNN} (ex. 86-TP-0001), UNIQUE PAR SITE
|
||||
* et immuable. Chaque site porte sa propre sequence : 86-TP-0001 et 17-TP-0001
|
||||
* coexistent.
|
||||
*
|
||||
* Le code du site (prefixe) vit sur l'entite Site (site.code, ERP-183) — d'ou le
|
||||
* type-hint sur Site concret (et non SiteInterface qui n'expose pas getCode()) ;
|
||||
* c'est la meme reference ORM partagee que celle consommee par WeighingTicket
|
||||
* (§ 2.1, pas de logique inter-module).
|
||||
*
|
||||
* Port (couche Application) ; l'implementation (WeighingTicketNumberAllocator,
|
||||
* Infrastructure) incremente le compteur weighing_ticket_counter sous verrou ligne
|
||||
* `SELECT ... FOR UPDATE` pour garantir l'unicite meme en concurrence.
|
||||
*/
|
||||
interface WeighingTicketNumberAllocatorInterface
|
||||
{
|
||||
/**
|
||||
* Attribue et renvoie le prochain numero formate {siteCode}-TP-{NNNN} pour le
|
||||
* site, en persistant l'increment de maniere atomique (verrou ligne).
|
||||
*/
|
||||
public function allocate(Site $site): string;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Domain\Contract;
|
||||
|
||||
use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException;
|
||||
use App\Module\Logistique\Domain\Weighbridge\WeighbridgeReading;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
|
||||
/**
|
||||
* Contrat de lecture du pont bascule (§ 2.6).
|
||||
*
|
||||
* Abstraction posee au M5 pour decoupler l'API du materiel : l'implementation
|
||||
* livree est un stub (RandomWeighbridgeReader, poids aleatoire ∈ [10000,50000]
|
||||
* kg). Le driver materiel reel (protocole serie/TCP de l'indicateur de pesage)
|
||||
* est hors perimetre M5 (HP-M5-02) : le jour venu on substitue l'implementation
|
||||
* derriere cette interface — zero impact sur les ecrans / l'API.
|
||||
*/
|
||||
interface WeighbridgeReaderInterface
|
||||
{
|
||||
/**
|
||||
* Effectue une pesee « bascule » (AUTO) pour le site donne : renvoie le poids
|
||||
* lu et le DSD (index de pesee du pont) attribue pour ce site (RG-5.04).
|
||||
*
|
||||
* @throws WeighbridgeUnavailableException si la bascule ne repond pas
|
||||
* (le Processor traduit en HTTP 503 →
|
||||
* bascule manuelle, RG-5.06)
|
||||
*/
|
||||
public function read(SiteInterface $site): WeighbridgeReading;
|
||||
}
|
||||
@@ -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,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Levee quand une immatriculation ne respecte pas le masque SIV XX-000-XX alors
|
||||
* que « Tout format » n'est PAS coche (plateFreeFormat = false, RG-5.01).
|
||||
*
|
||||
* Exception de DOMAINE (pure, sans dependance HTTP) levee par le
|
||||
* WeighingTicketFieldNormalizer : c'est le WeighingTicketProcessor qui la traduit
|
||||
* en 422 portant un propertyPath « immatriculation » (mapping inline useFormErrors,
|
||||
* convention ERP-101) plutot qu'un toast.
|
||||
*/
|
||||
final class InvalidImmatriculationException extends RuntimeException {}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Levee lorsque le pont bascule ne repond pas / est indisponible (RG-5.06).
|
||||
*
|
||||
* Exception de DOMAINE (pure, sans dependance HTTP) : c'est le Processor de
|
||||
* l'endpoint de pesee qui la traduit en reponse HTTP 503 « Pont bascule
|
||||
* indisponible — passez en pesee manuelle » (cf. WeighbridgeReadingProcessor).
|
||||
*
|
||||
* Au M5, le stub (RandomWeighbridgeReader) ne la leve jamais, mais le chemin
|
||||
* d'erreur est implemente et teste pour le jour ou un driver materiel reel
|
||||
* (HP-M5-02) sera branche derriere WeighbridgeReaderInterface.
|
||||
*/
|
||||
final class WeighbridgeUnavailableException extends RuntimeException {}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?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 + fetch-join client/supplier/site)
|
||||
* 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 : un provider custom
|
||||
* court-circuite le SiteScopedQueryExtension (qui n'agit que dans le provider
|
||||
* ORM standard), donc le WeighingTicketProvider l'applique lui-meme (§ 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,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Domain\Weighbridge;
|
||||
|
||||
/**
|
||||
* Resultat immuable d'une lecture du pont bascule (§ 2.6 / RG-5.06).
|
||||
*
|
||||
* Porte le couple {poids, DSD} renvoye par une pesee « bascule » (AUTO) :
|
||||
* - weight : poids brut lu, en kilogrammes ;
|
||||
* - dsd : index de pesee du pont (compteur par site, RG-5.04).
|
||||
*
|
||||
* Au M5 le pont est un stub (RandomWeighbridgeReader) ; un driver materiel reel
|
||||
* (HP-M5-02) produira le meme objet derriere WeighbridgeReaderInterface, sans
|
||||
* impact sur l'API.
|
||||
*/
|
||||
final readonly class WeighbridgeReading
|
||||
{
|
||||
public function __construct(
|
||||
public int $weight,
|
||||
public int $dsd,
|
||||
) {}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighbridgeReadingProcessor;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* Ressource API Platform virtuelle (non mappee Doctrine) portant l'action de
|
||||
* pesee au pont bascule : `POST /api/weighbridge_readings` (§ 4.2).
|
||||
*
|
||||
* Action AUTONOME : declenchee depuis le formulaire AVANT que le ticket existe.
|
||||
* Le site est resolu serveur (site courant) — jamais envoye par le client.
|
||||
*
|
||||
* - AUTO (`{ "mode": "AUTO" }`) → `{ weight, dsd, mode }` (stub : poids
|
||||
* aleatoire ∈ [10000,50000] kg + DSD du site, RG-5.04 / RG-5.06).
|
||||
* - MANUAL (`{ "mode": "MANUAL", "weight": <int>, "manualNumber": "<str>" }`)
|
||||
* → `{ weight, dsd, manualNumber, mode }` (DSD = dernier DSD du site + 1).
|
||||
*
|
||||
* `read: false` : pas de chargement d'entite existante — le payload est
|
||||
* denormalise directement dans cette ressource, puis le Processor prend le relais.
|
||||
*
|
||||
* ⚠ Le `dsd` renvoye ici est PREVISIONNEL : l'attribution AUTORITAIRE du DSD
|
||||
* (et du numero de ticket) est refaite/verrouillee a la creation du ticket
|
||||
* (`POST /api/weighing_tickets`, ERP-185) pour eviter les collisions si deux
|
||||
* postes pesent en parallele. Le front affiche cette valeur, mais c'est le
|
||||
* ticket persiste qui fait foi.
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'WeighbridgeReading',
|
||||
operations: [
|
||||
new Post(
|
||||
uriTemplate: '/weighbridge_readings',
|
||||
// Action de lecture du pont (pas une creation de ressource) : 200, pas 201.
|
||||
status: 200,
|
||||
security: "is_granted('logistique.weighing_tickets.manage')",
|
||||
normalizationContext: ['groups' => ['weighbridge_reading:read']],
|
||||
denormalizationContext: ['groups' => ['weighbridge_reading:write']],
|
||||
processor: WeighbridgeReadingProcessor::class,
|
||||
read: false,
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class WeighbridgeReadingResource
|
||||
{
|
||||
/** AUTO (pesee bascule) | MANUAL (pesee manuelle) — pilote le comportement (§ 4.2). */
|
||||
#[Assert\NotBlank(message: 'Le mode de pesée est obligatoire.')]
|
||||
#[Assert\Choice(choices: ['AUTO', 'MANUAL'], message: 'Mode de pesée invalide (AUTO ou MANUAL).')]
|
||||
#[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])]
|
||||
public ?string $mode = null;
|
||||
|
||||
/**
|
||||
* Poids en kg. En entree : requis et saisi en MANUAL, ignore en AUTO (le pont
|
||||
* fournit le poids). En sortie : poids effectif de la pesee.
|
||||
*/
|
||||
#[Assert\Positive(message: 'Le poids doit être un entier positif (kg).')]
|
||||
#[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])]
|
||||
public ?int $weight = null;
|
||||
|
||||
/** Numero de pesee papier saisi en MANUAL (distinct du DSD, RG-5.04). */
|
||||
#[Assert\Length(max: 50, maxMessage: 'Le numéro de pesée ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])]
|
||||
public ?string $manualNumber = null;
|
||||
|
||||
/** DSD attribue par le serveur (lecture seule) — previsionnel (cf. docbloc classe). */
|
||||
#[Groups(['weighbridge_reading:read'])]
|
||||
public ?int $dsd = null;
|
||||
|
||||
/**
|
||||
* RG metier : en pesee MANUAL, le poids est saisi par l'operateur (le pont
|
||||
* n'est pas lu) → il est obligatoire. Porte par un Callback pour que le 422
|
||||
* cible le propertyPath `weight` (mapping inline front, ERP-101). En AUTO,
|
||||
* le poids fourni par le client est ignore (renseigne par le pont).
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateManualWeight(ExecutionContextInterface $context): void
|
||||
{
|
||||
if ('MANUAL' === $this->mode && null === $this->weight) {
|
||||
$context->buildViolation('Le poids est obligatoire en pesée manuelle.')
|
||||
->atPath('weight')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
|
||||
use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface;
|
||||
use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException;
|
||||
use App\Module\Logistique\Infrastructure\ApiPlatform\Resource\WeighbridgeReadingResource;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use LogicException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
|
||||
|
||||
/**
|
||||
* Processor de l'action `POST /api/weighbridge_readings` (§ 4.2).
|
||||
*
|
||||
* Resout le site courant (CurrentSiteProviderInterface — contrat Sites, seule
|
||||
* logique cross-module autorisee, regle ABSOLUE n°1) puis :
|
||||
* - AUTO : lit le pont (WeighbridgeReaderInterface) → poids + DSD. Si la
|
||||
* bascule est indisponible (WeighbridgeUnavailableException) → HTTP 503
|
||||
* « Pont bascule indisponible — passez en pesee manuelle » (RG-5.06).
|
||||
* - MANUAL : conserve le poids saisi et alloue le DSD (dernier + 1, RG-5.04).
|
||||
*
|
||||
* @implements ProcessorInterface<WeighbridgeReadingResource, WeighbridgeReadingResource>
|
||||
*/
|
||||
final class WeighbridgeReadingProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
private readonly WeighbridgeReaderInterface $weighbridgeReader,
|
||||
private readonly DsdAllocatorInterface $dsdAllocator,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): WeighbridgeReadingResource
|
||||
{
|
||||
if (!$data instanceof WeighbridgeReadingResource) {
|
||||
throw new LogicException(sprintf(
|
||||
'WeighbridgeReadingProcessor attend une instance de %s, %s recu.',
|
||||
WeighbridgeReadingResource::class,
|
||||
get_debug_type($data),
|
||||
));
|
||||
}
|
||||
|
||||
// Site courant resolu serveur (jamais envoye par le client). Absent =
|
||||
// aucun site selectionne dans le sélecteur → on ne peut pas peser.
|
||||
$site = $this->currentSiteProvider->get();
|
||||
if (null === $site) {
|
||||
throw new BadRequestHttpException('Aucun site courant sélectionné — sélectionnez un site avant de peser.');
|
||||
}
|
||||
|
||||
if ('AUTO' === $data->mode) {
|
||||
try {
|
||||
$reading = $this->weighbridgeReader->read($site);
|
||||
} catch (WeighbridgeUnavailableException $e) {
|
||||
// RG-5.06 : le pont ne repond pas → 503 explicite, le front bascule
|
||||
// en pesee manuelle. (Le stub M5 ne leve jamais — chemin teste.)
|
||||
throw new ServiceUnavailableHttpException(
|
||||
null,
|
||||
'Pont bascule indisponible — passez en pesée manuelle.',
|
||||
$e,
|
||||
);
|
||||
}
|
||||
|
||||
$data->weight = $reading->weight;
|
||||
$data->dsd = $reading->dsd;
|
||||
$data->manualNumber = null; // pas de numero papier en mode bascule
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
// MANUAL : le poids est saisi (validateManualWeight garantit sa presence),
|
||||
// seul le DSD est attribue serveur (dernier DSD du site + 1, RG-5.04).
|
||||
$data->dsd = $this->dsdAllocator->next($site);
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
|
||||
use App\Module\Logistique\Application\Service\WeighingTicketFieldNormalizer;
|
||||
use App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface;
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use App\Module\Logistique\Domain\Exception\InvalidImmatriculationException;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture du ticket de pesee (M5). Cf. spec-back M5 § 4.3 / § 4.4 +
|
||||
* RG-5.01 / RG-5.02 / RG-5.03 / RG-5.04 / RG-5.05 / RG-5.09 / RG-5.10. Jumeau des
|
||||
* processors M2/M3/M4, recentre sur les regles specifiques du ticket de pesee.
|
||||
*
|
||||
* Sequence (POST / PATCH) — l'entite arrive deja VALIDEE (les Assert + le Callback
|
||||
* RG-5.03 ont joue en amont) :
|
||||
* 1. CREATION uniquement (RG-5.09, immuables) : resolution du site courant
|
||||
* (CurrentSiteProviderInterface — seule logique cross-module autorisee, regle
|
||||
* ABSOLUE n°1) puis attribution du numero {siteCode}-TP-{NNNN} (compteur
|
||||
* verrouille, RG-5.02). Le PATCH ne retouche ni site ni numero.
|
||||
* 2. Coherence contrepartie (RG-5.03) : null-ification des champs hors-branche
|
||||
* selon counterpartyType (la PRESENCE du champ requis est deja validee par le
|
||||
* Callback de l'entite ; ici on garantit l'EXCLUSIVITE — sinon les CHECK
|
||||
* Postgres chk_wt_*_branch leveraient une 500 generique).
|
||||
* 3. Normalisation immatriculation (RG-5.01 / RG-5.10) : trim + UPPER + masque
|
||||
* XX-000-XX si !plateFreeFormat. Format invalide -> 422 sur « immatriculation »
|
||||
* (mapping inline useFormErrors, ERP-101).
|
||||
* 4. DSD autoritaire (RG-5.04) : pour chaque pesee AUTO, (re)attribution du DSD
|
||||
* via DsdAllocator (verrou FOR UPDATE). Le DSD renvoye par
|
||||
* POST /api/weighbridge_readings est PREVISIONNEL ; l'attribution autoritaire
|
||||
* est faite ici. Une pesee MANUELLE conserve le DSD deja alloue par l'endpoint
|
||||
* de pesee (« dernier + 1 », round-trip par le client, deja consomme).
|
||||
* 5. Poids net (RG-5.05) : net_weight = full_weight - empty_weight si les deux
|
||||
* poids sont presents, sinon null.
|
||||
* 6. Persistance via le persist_processor Doctrine.
|
||||
*
|
||||
* @implements ProcessorInterface<WeighingTicket, WeighingTicket>
|
||||
*/
|
||||
final class WeighingTicketProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
private readonly WeighingTicketNumberAllocatorInterface $numberAllocator,
|
||||
private readonly DsdAllocatorInterface $dsdAllocator,
|
||||
private readonly WeighingTicketFieldNormalizer $normalizer,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof WeighingTicket) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
// Une entite non geree par l'ORM = creation (POST) : site + numero ne sont
|
||||
// attribues qu'a ce moment et restent immuables ensuite (RG-5.09).
|
||||
$isNew = !$this->em->contains($data);
|
||||
|
||||
if ($isNew) {
|
||||
$site = $this->resolveCurrentSite();
|
||||
$data->setSite($site);
|
||||
$data->setNumber($this->numberAllocator->allocate($site));
|
||||
}
|
||||
|
||||
$this->applyCounterpartyExclusivity($data);
|
||||
$this->normalizeImmatriculation($data);
|
||||
|
||||
// Le site est toujours present apres creation ; sur PATCH il est charge
|
||||
// depuis la base. Garde defensive si jamais il manque (ne devrait pas).
|
||||
$site = $data->getSite();
|
||||
if ($site instanceof Site) {
|
||||
$this->allocateAutoDsd($data, $site, $isNew);
|
||||
}
|
||||
|
||||
$this->computeNetWeight($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resout le site courant (sélecteur de site). Absent = aucun site selectionne
|
||||
* -> 400 : on ne peut pas numeroter ni rattacher un ticket sans site (site_id
|
||||
* NOT NULL, § 2.3).
|
||||
*/
|
||||
private function resolveCurrentSite(): Site
|
||||
{
|
||||
$site = $this->currentSiteProvider->get();
|
||||
if (!$site instanceof Site) {
|
||||
throw new BadRequestHttpException('Aucun site courant sélectionné — sélectionnez un site avant de créer un ticket de pesée.');
|
||||
}
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-5.03 : garantit l'exclusivite de la contrepartie en forcant a null les
|
||||
* champs hors-branche selon counterpartyType. La PRESENCE du champ requis est
|
||||
* deja validee en amont (Assert\Callback de l'entite) ; ici on evite qu'un
|
||||
* payload portant a la fois client_id ET supplier_id ne fasse echouer les CHECK
|
||||
* Postgres (500 generique au lieu d'une donnee coherente). otherLabel est
|
||||
* normalise (trim) dans la branche AUTRE.
|
||||
*/
|
||||
private function applyCounterpartyExclusivity(WeighingTicket $data): void
|
||||
{
|
||||
switch ($data->getCounterpartyType()) {
|
||||
case 'CLIENT':
|
||||
$data->setSupplier(null);
|
||||
$data->setOtherLabel(null);
|
||||
|
||||
break;
|
||||
|
||||
case 'FOURNISSEUR':
|
||||
$data->setClient(null);
|
||||
$data->setOtherLabel(null);
|
||||
|
||||
break;
|
||||
|
||||
case 'AUTRE':
|
||||
$data->setClient(null);
|
||||
$data->setSupplier(null);
|
||||
$data->setOtherLabel($this->normalizer->normalizeOtherLabel($data->getOtherLabel()));
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-5.01 / RG-5.10 : normalisation serveur de l'immatriculation (trim + UPPER
|
||||
* + masque XX-000-XX hors « Tout format »). Un format invalide est traduit en
|
||||
* 422 portant un propertyPath « immatriculation » consommable inline par
|
||||
* useFormErrors (ERP-101), plutot qu'un toast.
|
||||
*/
|
||||
private function normalizeImmatriculation(WeighingTicket $data): void
|
||||
{
|
||||
$current = $data->getImmatriculation();
|
||||
if (null === $current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$data->setImmatriculation(
|
||||
$this->normalizer->normalizeImmatriculation($current, $data->isPlateFreeFormat()),
|
||||
);
|
||||
} catch (InvalidImmatriculationException $e) {
|
||||
$this->throwFieldViolation($data, 'immatriculation', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-5.04 : (re)attribution AUTORITAIRE du DSD pour chaque pesee AUTO via
|
||||
* DsdAllocator (verrou FOR UPDATE). A la creation, le DSD prévisionnel envoye
|
||||
* par le client (issu de POST /api/weighbridge_readings) est ecrase. Sur PATCH,
|
||||
* on n'alloue que pour une pesee AUTO encore depourvue de DSD (ex. la pesee a
|
||||
* plein realisee apres coup) — sinon on churne le compteur a chaque edition.
|
||||
* Les pesees MANUELLES conservent leur DSD (deja alloue par l'endpoint de
|
||||
* pesee, « dernier + 1 »).
|
||||
*/
|
||||
private function allocateAutoDsd(WeighingTicket $data, Site $site, bool $isNew): void
|
||||
{
|
||||
if ('AUTO' === $data->getEmptyMode() && ($isNew || null === $data->getEmptyDsd())) {
|
||||
$data->setEmptyDsd($this->dsdAllocator->next($site));
|
||||
}
|
||||
|
||||
if ('AUTO' === $data->getFullMode() && ($isNew || null === $data->getFullDsd())) {
|
||||
$data->setFullDsd($this->dsdAllocator->next($site));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-5.05 : poids net = poids plein - poids vide (kg), recalcule a chaque
|
||||
* ecriture. Null tant que l'une des deux pesees manque.
|
||||
*/
|
||||
private function computeNetWeight(WeighingTicket $data): void
|
||||
{
|
||||
$empty = $data->getEmptyWeight();
|
||||
$full = $data->getFullWeight();
|
||||
|
||||
$data->setNetWeight(null !== $empty && null !== $full ? $full - $empty : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leve une 422 portant une violation unique sur un champ — meme rendu Hydra que
|
||||
* les contraintes Symfony, consommable inline par useFormErrors (ERP-101).
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
private function throwFieldViolation(WeighingTicket $root, string $propertyPath, string $message): void
|
||||
{
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation($message, null, [], $root, $propertyPath, null));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\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\Logistique\Domain\Entity\WeighingTicket;
|
||||
use App\Module\Logistique\Domain\Repository\WeighingTicketRepositoryInterface;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider de lecture des tickets de pesee (M5). Cf. spec-back M5 § 4.0 / § 4.1 +
|
||||
* RG-5.09. Jumeau du SupplierProvider (M2), augmente du cloisonnement par site.
|
||||
*
|
||||
* Collection (GET /api/weighing_tickets) :
|
||||
* - exclut les soft-deletes (deleted_at IS NOT NULL, prepares mais non exposes au
|
||||
* M5 — § 2.13), via le repository ;
|
||||
* - filtre ?search=... (fuzzy sur number, nom client/fournisseur, other_label,
|
||||
* immatriculation — § 4.1) ;
|
||||
* - tri ?order[displayDate]=asc|desc (date du ticket = COALESCE full/empty),
|
||||
* defaut number DESC (plus recents en tete) ;
|
||||
* - pagination obligatoire (regle ABSOLUE n°13) : Paginator ORM ; echappatoire
|
||||
* ?pagination=false ;
|
||||
* - fetch-join client / supplier / site (ManyToOne surs) pour eviter le N+1 a la
|
||||
* serialisation (§ 4.0).
|
||||
*
|
||||
* Cloisonnement par site (§ 2.3 / RG-5.09) — applique ICI : un provider custom
|
||||
* REMPLACE le provider Doctrine, donc le SiteScopedQueryExtension ne s'execute pas
|
||||
* automatiquement (il n'agit que dans le provider ORM standard). On replique sa
|
||||
* logique a l'identique :
|
||||
* - user `sites.bypass_scope` (Admin auto, consolidation) -> aucun filtre ;
|
||||
* - site courant null (module Sites off / user sans site) -> no-op (l'user voit
|
||||
* tout, decision site-aware.md § 5) ;
|
||||
* - sinon -> liste restreinte aux tickets du site courant, AVANT pagination
|
||||
* (totalItems reflete le perimetre).
|
||||
*
|
||||
* Item (GET /api/weighing_tickets/{id} + provider de PATCH) :
|
||||
* - 404 si introuvable OU soft-delete (deleted_at non null) ;
|
||||
* - 404 si hors perimetre site (ne pas reveler l'existence d'une ligne d'un autre
|
||||
* site — anti-enumeration).
|
||||
*
|
||||
* @implements ProviderInterface<WeighingTicket>
|
||||
*/
|
||||
final class WeighingTicketProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository')]
|
||||
private readonly WeighingTicketRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|WeighingTicket|null
|
||||
{
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
return $this->provideCollection($operation, $context);
|
||||
}
|
||||
|
||||
return $this->provideItem($uriVariables);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*
|
||||
* @return list<WeighingTicket>|Paginator<WeighingTicket>
|
||||
*/
|
||||
private function provideCollection(Operation $operation, array $context): array|Paginator
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
$search = $filters['search'] ?? null;
|
||||
|
||||
$qb = $this->repository->createListQueryBuilder(is_string($search) ? $search : null);
|
||||
|
||||
$this->applyDisplayDateOrder($qb, $filters);
|
||||
$this->applySiteScope($qb);
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||
// (regle n°13 — utile pour alimenter un <select> cote front).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
/** @var list<WeighingTicket> $tickets */
|
||||
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);
|
||||
|
||||
// Les fetch-joins du repository sont tous ManyToOne (client/supplier/site) :
|
||||
// pas de demultiplication de lignes -> fetchJoinCollection: false (COUNT
|
||||
// simple, page correcte).
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $uriVariables
|
||||
*/
|
||||
private function provideItem(array $uriVariables): ?WeighingTicket
|
||||
{
|
||||
$id = $uriVariables['id'] ?? null;
|
||||
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ticket = $this->repository->findById((int) $id);
|
||||
if (null === $ticket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Soft-delete : jamais expose au M5 (§ 2.13) -> 404 via retour null.
|
||||
if (null !== $ticket->getDeletedAt()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cloisonnement par site : un ticket hors perimetre -> 404 (anti-enumeration).
|
||||
$scopeSite = $this->currentScopeSite();
|
||||
if (null !== $scopeSite && $ticket->getSite()?->getId() !== $scopeSite->getId()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tri par date du ticket (§ 4.1) : displayDate = full_date ?? empty_date, donc
|
||||
* un getter calcule (pas une colonne) -> on trie sur l'expression DQL
|
||||
* COALESCE(full_date, empty_date). Absent du payload -> on garde le tri par
|
||||
* defaut du repository (number DESC).
|
||||
*
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
private function applyDisplayDateOrder(QueryBuilder $qb, array $filters): void
|
||||
{
|
||||
$order = $filters['order'] ?? null;
|
||||
if (!is_array($order) || !isset($order['displayDate'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$direction = 'asc' === strtolower((string) $order['displayDate']) ? 'ASC' : 'DESC';
|
||||
$rootAlias = $qb->getRootAliases()[0];
|
||||
|
||||
$qb->orderBy(sprintf('COALESCE(%1$s.fullDate, %1$s.emptyDate)', $rootAlias), $direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restreint la liste au site courant si l'user n'a pas le bypass et qu'un site
|
||||
* est selectionne (cf. docblock de classe). No-op sinon.
|
||||
*/
|
||||
private function applySiteScope(QueryBuilder $qb): void
|
||||
{
|
||||
$scopeSite = $this->currentScopeSite();
|
||||
if (null === $scopeSite) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rootAlias = $qb->getRootAliases()[0];
|
||||
$qb->andWhere(sprintf('%s.site = :scopeSite', $rootAlias))
|
||||
->setParameter('scopeSite', $scopeSite)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site servant a cloisonner, ou null si aucun cloisonnement ne s'applique
|
||||
* (bypass_scope, ou pas de site courant). Replique les conditions de
|
||||
* SiteScopedQueryExtension.
|
||||
*/
|
||||
private function currentScopeSite(): ?Site
|
||||
{
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->currentSiteProvider->get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use App\Module\Logistique\Domain\Repository\WeighingTicketRepositoryInterface;
|
||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketProvider;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Export XLSX de la liste des tickets de pesee (M5, spec-back § 4.5 — bouton
|
||||
* « Exporter » : « Exporte toute la liste des tickets de pesée »). Jumeau des
|
||||
* controllers d'export SupplierExportController (M2) / ProviderExportController
|
||||
* (M3) — references en prose volontairement (un {@see} inter-module violerait la
|
||||
* regle ABSOLUE n°1).
|
||||
*
|
||||
* 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/weighing_tickets/export.xlsx`
|
||||
* comme l'item `GET /api/weighing_tickets/{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 tickets (MEMES criteres que la liste
|
||||
* `GET /api/weighing_tickets`, mais SANS pagination — export complet § 4.5) et
|
||||
* mapping metier des colonnes.
|
||||
*
|
||||
* Filtrage : on rejoue EXACTEMENT la selection du {@see WeighingTicketProvider}
|
||||
* pour que l'export reflete ce que l'utilisateur voit a l'ecran :
|
||||
* - recherche fuzzy ?search (number, nom client/fournisseur, other_label, immat) ;
|
||||
* - tri ?order[displayDate]=asc|desc (defaut number DESC) ;
|
||||
* - cloisonnement par site courant (§ 2.3 / RG-5.09) : un user sans
|
||||
* `sites.bypass_scope` possedant un site courant n'exporte que les tickets de
|
||||
* ce site. La decision est prise ICI (l'user), le filtre DQL sur wt.site est
|
||||
* pose sur le QueryBuilder. No-op pour bypass_scope ou site courant null.
|
||||
*/
|
||||
#[AsController]
|
||||
final class WeighingTicketExportController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository')]
|
||||
private readonly WeighingTicketRepositoryInterface $repository,
|
||||
private readonly SpreadsheetExporterInterface $exporter,
|
||||
private readonly Security $security,
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
) {}
|
||||
|
||||
#[Route('/api/weighing_tickets/export.xlsx', name: 'logistique_weighing_tickets_export_xlsx', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('logistique.weighing_tickets.view')]
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$search = $request->query->getString('search') ?: null;
|
||||
|
||||
$qb = $this->repository->createListQueryBuilder($search);
|
||||
|
||||
$this->applyDisplayDateOrder($qb, $request->query->all());
|
||||
$this->applySiteScope($qb);
|
||||
|
||||
// Export complet : pas de pagination (§ 4.5). On materialise toute la
|
||||
// selection filtree (cloisonnee par site) AVANT le mapping des colonnes.
|
||||
/** @var list<WeighingTicket> $tickets */
|
||||
$tickets = $qb->getQuery()->getResult();
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
'Tickets de pesée',
|
||||
$this->buildHeaders(),
|
||||
$this->buildRows($tickets),
|
||||
);
|
||||
|
||||
return $this->buildResponse($binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tri par date du ticket (§ 4.1), miroir de WeighingTicketProvider :
|
||||
* displayDate = COALESCE(full_date, empty_date) (getter calcule, pas une
|
||||
* colonne). Absent du payload -> tri par defaut du repository (number DESC).
|
||||
*
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private function applyDisplayDateOrder(QueryBuilder $qb, array $query): void
|
||||
{
|
||||
$order = $query['order'] ?? null;
|
||||
if (!is_array($order) || !isset($order['displayDate'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$direction = 'asc' === strtolower((string) $order['displayDate']) ? 'ASC' : 'DESC';
|
||||
$rootAlias = $qb->getRootAliases()[0];
|
||||
|
||||
$qb->orderBy(sprintf('COALESCE(%1$s.fullDate, %1$s.emptyDate)', $rootAlias), $direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloisonnement par site courant (§ 2.3 / RG-5.09), miroir de
|
||||
* WeighingTicketProvider::applySiteScope() : restreint la selection au site
|
||||
* courant si l'user n'a pas le bypass et qu'un site est resolu. No-op sinon.
|
||||
*/
|
||||
private function applySiteScope(QueryBuilder $qb): void
|
||||
{
|
||||
$scopeSite = $this->siteScopeOrNull();
|
||||
if (null === $scopeSite) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rootAlias = $qb->getRootAliases()[0];
|
||||
$qb->andWhere(sprintf('%s.site = :scopeSite', $rootAlias))
|
||||
->setParameter('scopeSite', $scopeSite)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site servant a cloisonner, ou null si aucun cloisonnement ne s'applique
|
||||
* (user `sites.bypass_scope`, ou pas de site courant — module Sites off /
|
||||
* user sans currentSite). Miroir de WeighingTicketProvider::currentScopeSite().
|
||||
*/
|
||||
private function siteScopeOrNull(): ?SiteInterface
|
||||
{
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->currentSiteProvider->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes de l'export (spec § 4.5).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Numéro',
|
||||
'Type contrepartie',
|
||||
'Contrepartie',
|
||||
'Date',
|
||||
'Immatriculation',
|
||||
'Poids vide (kg)',
|
||||
'Poids plein (kg)',
|
||||
'Poids net (kg)',
|
||||
'DSD vide',
|
||||
'DSD plein',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<WeighingTicket> $tickets
|
||||
*
|
||||
* @return iterable<list<null|scalar>>
|
||||
*/
|
||||
private function buildRows(array $tickets): iterable
|
||||
{
|
||||
foreach ($tickets as $ticket) {
|
||||
yield [
|
||||
$ticket->getNumber(),
|
||||
$this->counterpartyTypeLabel($ticket->getCounterpartyType()),
|
||||
$this->counterpartyName($ticket),
|
||||
$ticket->getDisplayDate()?->format('d/m/Y H:i') ?? '',
|
||||
$ticket->getImmatriculation() ?? '',
|
||||
$ticket->getEmptyWeight() ?? '',
|
||||
$ticket->getFullWeight() ?? '',
|
||||
$ticket->getNetWeight() ?? '',
|
||||
$ticket->getEmptyDsd() ?? '',
|
||||
$ticket->getFullDsd() ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelle FR du type de contrepartie (RG-5.03). Renvoie la valeur brute pour
|
||||
* une valeur inattendue (garde-fou : ne masque pas une donnee corrompue).
|
||||
*/
|
||||
private function counterpartyTypeLabel(?string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'CLIENT' => 'Client',
|
||||
'FOURNISSEUR' => 'Fournisseur',
|
||||
'AUTRE' => 'Autre',
|
||||
default => $type ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Nom de la contrepartie selon le type (RG-5.03) : raison sociale du client,
|
||||
* du fournisseur, ou libelle libre « Autre ». Client / Supplier sont
|
||||
* fetch-joines par le repository (anti N+1, § 4.0).
|
||||
*/
|
||||
private function counterpartyName(WeighingTicket $ticket): string
|
||||
{
|
||||
return match ($ticket->getCounterpartyType()) {
|
||||
'CLIENT' => $ticket->getClient()?->getCompanyName() ?? '',
|
||||
'FOURNISSEUR' => $ticket->getSupplier()?->getCompanyName() ?? '',
|
||||
'AUTRE' => $ticket->getOtherLabel() ?? '',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function buildResponse(string $binary): Response
|
||||
{
|
||||
$filename = sprintf('tickets-pesee-%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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?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
|
||||
{
|
||||
// Fetch-join (addSelect) des relations ManyToOne client / supplier / site :
|
||||
// sert a la fois la recherche par nom et l'anti-N+1 a la serialisation
|
||||
// (§ 4.0 / § 4.1) — aucune demultiplication de lignes (cardinalite to-one).
|
||||
// Le cloisonnement par site courant n'est PAS pose ici : un provider custom
|
||||
// court-circuite le SiteScopedQueryExtension, le WeighingTicketProvider
|
||||
// l'applique donc lui-meme (§ 2.3). Tri par defaut number DESC.
|
||||
$qb = $this->createQueryBuilder('wt')
|
||||
->leftJoin('wt.client', 'c')->addSelect('c')
|
||||
->leftJoin('wt.supplier', 's')->addSelect('s')
|
||||
->leftJoin('wt.site', 'st')->addSelect('st')
|
||||
->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,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\Service;
|
||||
|
||||
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* Implementation DBAL de l'allocateur DSD (RG-5.04, § 2.7).
|
||||
*
|
||||
* Le compteur vit dans la table `weighbridge_dsd_counter (site_id PK,
|
||||
* last_value)` — jamais mappee en ORM (DBAL brut, exclue du schema_filter).
|
||||
* L'increment est realise dans une transaction avec verrou ligne
|
||||
* `SELECT ... FOR UPDATE` : deux postes pesant en parallele sur le meme site
|
||||
* sont serialises, ce qui garantit des DSD distincts (pas de collision).
|
||||
*
|
||||
* AUTO comme MANUAL passent par le meme increment (« dernier DSD du site + 1 ») :
|
||||
* la seule difference fonctionnelle est l'origine du poids (lu par le pont en
|
||||
* AUTO, saisi en MANUAL), pas la sequence DSD.
|
||||
*
|
||||
* La ligne compteur n'est pas seedee a la creation du site : on la cree a la
|
||||
* volee (INSERT ... ON CONFLICT DO NOTHING) avant de prendre le verrou.
|
||||
*/
|
||||
final class DsdAllocator implements DsdAllocatorInterface
|
||||
{
|
||||
public function __construct(private readonly Connection $connection) {}
|
||||
|
||||
public function next(SiteInterface $site): int
|
||||
{
|
||||
$siteId = $site->getId();
|
||||
if (null === $siteId) {
|
||||
// Garde defensive : un site non persiste n'a pas de compteur (et la FK
|
||||
// weighbridge_dsd_counter.site_id -> site(id) rejetterait l'INSERT).
|
||||
throw new LogicException('Impossible d\'allouer un DSD pour un site non persiste (id null).');
|
||||
}
|
||||
|
||||
return $this->connection->transactional(function (Connection $conn) use ($siteId): int {
|
||||
// Garantit l'existence de la ligne compteur du site sans ecraser une
|
||||
// valeur deja presente (idempotent, concurrence-safe).
|
||||
$conn->executeStatement(
|
||||
'INSERT INTO weighbridge_dsd_counter (site_id, last_value) VALUES (:site, 0) ON CONFLICT (site_id) DO NOTHING',
|
||||
['site' => $siteId],
|
||||
);
|
||||
|
||||
// Verrou ligne : serialise les pesees concurrentes du meme site.
|
||||
$current = (int) $conn->fetchOne(
|
||||
'SELECT last_value FROM weighbridge_dsd_counter WHERE site_id = :site FOR UPDATE',
|
||||
['site' => $siteId],
|
||||
);
|
||||
|
||||
$next = $current + 1;
|
||||
|
||||
$conn->executeStatement(
|
||||
'UPDATE weighbridge_dsd_counter SET last_value = :value WHERE site_id = :site',
|
||||
['value' => $next, 'site' => $siteId],
|
||||
);
|
||||
|
||||
return $next;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\Service;
|
||||
|
||||
use App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* Implementation DBAL de l'allocateur de numero de ticket (RG-5.02, § 2.5).
|
||||
*
|
||||
* Le compteur vit dans la table `weighing_ticket_counter (site_id PK,
|
||||
* last_value)` — jamais mappee en ORM (DBAL brut, exclue du schema_filter), meme
|
||||
* pattern que DsdAllocator. L'increment est realise dans une transaction avec
|
||||
* verrou ligne `SELECT ... FOR UPDATE` : deux postes creant un ticket en parallele
|
||||
* sur le meme site sont serialises -> numeros distincts, pas de collision sur
|
||||
* l'index unique uq_weighing_ticket_number (site_id, number).
|
||||
*
|
||||
* La ligne compteur n'est pas seedee a la creation du site : on la cree a la
|
||||
* volee (INSERT ... ON CONFLICT DO NOTHING) avant de prendre le verrou.
|
||||
*
|
||||
* Le numero est formate `{siteCode}-TP-%04d` (zero-padding 4 chiffres, debordement
|
||||
* naturel au-dela de 9999).
|
||||
*/
|
||||
final class WeighingTicketNumberAllocator implements WeighingTicketNumberAllocatorInterface
|
||||
{
|
||||
public function __construct(private readonly Connection $connection) {}
|
||||
|
||||
public function allocate(Site $site): string
|
||||
{
|
||||
$siteId = $site->getId();
|
||||
if (null === $siteId) {
|
||||
// Garde defensive : un site non persiste n'a pas de compteur (et la FK
|
||||
// weighing_ticket_counter.site_id -> site(id) rejetterait l'INSERT).
|
||||
throw new LogicException('Impossible d\'allouer un numero de ticket pour un site non persiste (id null).');
|
||||
}
|
||||
|
||||
$code = $site->getCode();
|
||||
if (null === $code || '' === trim($code)) {
|
||||
// site.code est NOT NULL (ERP-183) ; garde defensive pour les contextes
|
||||
// hors-flux (fixtures incompletes, site cree sans code).
|
||||
throw new LogicException(sprintf('Le site #%d n\'a pas de code de numerotation (site.code).', $siteId));
|
||||
}
|
||||
|
||||
$next = $this->connection->transactional(function (Connection $conn) use ($siteId): int {
|
||||
// Garantit l'existence de la ligne compteur du site sans ecraser une
|
||||
// valeur deja presente (idempotent, concurrence-safe).
|
||||
$conn->executeStatement(
|
||||
'INSERT INTO weighing_ticket_counter (site_id, last_value) VALUES (:site, 0) ON CONFLICT (site_id) DO NOTHING',
|
||||
['site' => $siteId],
|
||||
);
|
||||
|
||||
// Verrou ligne : serialise les creations concurrentes du meme site.
|
||||
$current = (int) $conn->fetchOne(
|
||||
'SELECT last_value FROM weighing_ticket_counter WHERE site_id = :site FOR UPDATE',
|
||||
['site' => $siteId],
|
||||
);
|
||||
|
||||
$nextValue = $current + 1;
|
||||
|
||||
$conn->executeStatement(
|
||||
'UPDATE weighing_ticket_counter SET last_value = :value WHERE site_id = :site',
|
||||
['value' => $nextValue, 'site' => $siteId],
|
||||
);
|
||||
|
||||
return $nextValue;
|
||||
});
|
||||
|
||||
return sprintf('%s-TP-%04d', $code, $next);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\Weighbridge;
|
||||
|
||||
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
|
||||
use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface;
|
||||
use App\Module\Logistique\Domain\Weighbridge\WeighbridgeReading;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
|
||||
/**
|
||||
* Stub du pont bascule livre au M5 (DECISION Matthieu 17/06, § 2.6 / RG-5.06).
|
||||
*
|
||||
* Aucune liaison materielle : la pesee « bascule » est simulee par un poids
|
||||
* aleatoire ∈ [10000, 50000] kg, et le DSD est attribue par l'allocateur de
|
||||
* site (DsdAllocator, RG-5.04). Le driver materiel reel (HP-M5-02) remplacera
|
||||
* cette classe derriere WeighbridgeReaderInterface sans impact sur l'API.
|
||||
*
|
||||
* Ce stub ne leve jamais WeighbridgeUnavailableException ; le chemin d'erreur
|
||||
* (→ 503) reste implemente et teste cote Processor.
|
||||
*/
|
||||
final class RandomWeighbridgeReader implements WeighbridgeReaderInterface
|
||||
{
|
||||
public function __construct(private readonly DsdAllocatorInterface $dsdAllocator) {}
|
||||
|
||||
public function read(SiteInterface $site): WeighbridgeReading
|
||||
{
|
||||
return new WeighbridgeReading(
|
||||
weight: random_int(10000, 50000),
|
||||
dsd: $this->dsdAllocator->next($site),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,9 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
'supplier:read',
|
||||
'supplier_address:read',
|
||||
'site:read',
|
||||
// Embarque le nom de fichier de la decharge (RG-4.02) au lieu d'un
|
||||
// IRI nu, pour l'affichage en consultation / modification (ERP-171).
|
||||
'uploaded_document:reference',
|
||||
'default:read',
|
||||
]],
|
||||
provider: CarrierProvider::class,
|
||||
@@ -195,10 +198,13 @@ class Carrier implements TimestampableInterface, BlamableInterface
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
private ?string $liotPlates = null;
|
||||
|
||||
// === Adresse UNIQUE (OneToOne) — EMBARQUEE dans le DETAIL (read-group sur le getter) ===
|
||||
// Metier : un transporteur a au plus UNE adresse (decision metier ERP-172). La
|
||||
// FK porte un index UNIQUE (cote CarrierAddress.carrier en OneToOne owning side).
|
||||
#[ORM\OneToOne(mappedBy: 'carrier', targetEntity: CarrierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private ?CarrierAddress $address = 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)]
|
||||
@@ -225,9 +231,8 @@ class Carrier implements TimestampableInterface, BlamableInterface
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->addresses = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
$this->prices = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
$this->prices = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -406,32 +411,22 @@ class Carrier implements TimestampableInterface, BlamableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CarrierAddress> */
|
||||
#[Groups(['carrier:item:read'])]
|
||||
public function getAddresses(): Collection
|
||||
public function getAddress(): ?CarrierAddress
|
||||
{
|
||||
return $this->addresses;
|
||||
return $this->address;
|
||||
}
|
||||
|
||||
public function addAddress(CarrierAddress $address): static
|
||||
public function setAddress(?CarrierAddress $address): static
|
||||
{
|
||||
if (!$this->addresses->contains($address)) {
|
||||
$this->addresses->add($address);
|
||||
$this->address = $address;
|
||||
if (null !== $address && $address->getCarrier() !== $this) {
|
||||
$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
|
||||
|
||||
@@ -20,9 +20,10 @@ 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).
|
||||
* Adresse d'un transporteur (1:1, OneToOne — decision metier ERP-172) — 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), et UNIQUE par transporteur (la jumelle M2 est 1:n).
|
||||
*
|
||||
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
|
||||
* transporteur). Ecriture : groupe `carrier:write:addresses`.
|
||||
@@ -30,9 +31,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
* 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
|
||||
* - POST /api/carriers/{carrierId}/address : creation rattachee au
|
||||
* transporteur parent (Link toProperty 'carrier'), security
|
||||
* transport.carriers.manage.
|
||||
* transport.carriers.manage. 409 si le transporteur a deja une adresse
|
||||
* (CarrierAddressProcessor::guardSingleAddress, avant la contrainte d'unicite).
|
||||
* - PATCH / DELETE /api/carrier_addresses/{id} : security
|
||||
* transport.carriers.manage.
|
||||
* - GET /api/carrier_addresses/{id} : lecture unitaire (security view) — la
|
||||
@@ -58,14 +60,13 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/carriers/{carrierId}/addresses',
|
||||
uriTemplate: '/carriers/{carrierId}/address',
|
||||
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 : pas de stade lecture du parent. Le parent est rattache
|
||||
// manuellement par CarrierAddressProcessor::linkParent (404 si absent),
|
||||
// qui refuse aussi une 2e adresse (RG metier : adresse UNIQUE — 409).
|
||||
read: false,
|
||||
security: "is_granted('transport.carriers.manage')",
|
||||
normalizationContext: ['groups' => ['carrier:item:read', 'default:read']],
|
||||
@@ -86,7 +87,9 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'carrier_address')]
|
||||
#[ORM\Index(name: 'idx_carrier_address_carrier', columns: ['carrier_id'])]
|
||||
// Adresse UNIQUE par transporteur (OneToOne owning side) : contrainte d'unicite
|
||||
// sur carrier_id (decision metier ERP-172).
|
||||
#[ORM\UniqueConstraint(name: 'uniq_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]
|
||||
@@ -100,7 +103,7 @@ class CarrierAddress implements TimestampableInterface, BlamableInterface
|
||||
#[Groups(['carrier:item:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Carrier::class, inversedBy: 'addresses')]
|
||||
#[ORM\OneToOne(targetEntity: Carrier::class, inversedBy: 'address')]
|
||||
#[ORM\JoinColumn(name: 'carrier_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Carrier $carrier = null;
|
||||
|
||||
@@ -133,9 +136,6 @@ class CarrierAddress implements TimestampableInterface, BlamableInterface
|
||||
#[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;
|
||||
@@ -212,16 +212,4 @@ class CarrierAddress implements TimestampableInterface, BlamableInterface
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ 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.
|
||||
* SupplierContact (M2) : au moins le prenom OU le nom (RG-4.08, garanti par le
|
||||
* CHECK chk_carrier_contact_name + le Processor), max 2 telephones.
|
||||
*
|
||||
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
|
||||
* transporteur). Ecriture : groupe `carrier:write:contacts`.
|
||||
|
||||
+27
-1
@@ -6,12 +6,14 @@ namespace App\Module\Transport\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
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\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
@@ -63,6 +65,7 @@ final class CarrierAddressProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->guardSingleAddress($data, $operation);
|
||||
$this->guardCharteredAddress($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
@@ -70,7 +73,7 @@ final class CarrierAddressProcessor implements ProcessorInterface
|
||||
|
||||
/**
|
||||
* Rattache l'adresse au transporteur parent de la sous-ressource POST
|
||||
* (/carriers/{carrierId}/addresses) : la relation n'est pas peuplee
|
||||
* (/carriers/{carrierId}/address) : 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
|
||||
@@ -98,6 +101,29 @@ final class CarrierAddressProcessor implements ProcessorInterface
|
||||
$address->setCarrier($carrier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse UNIQUE par transporteur (decision metier ERP-172) : un POST sur un
|
||||
* transporteur qui a deja une adresse -> 409 explicite (plutot qu'un 500 sur la
|
||||
* contrainte d'unicite carrier_id). No-op sur PATCH (mise a jour de l'adresse
|
||||
* existante). Lookup repository pour rester robuste a la synchro bidirectionnelle.
|
||||
*/
|
||||
private function guardSingleAddress(CarrierAddress $address, Operation $operation): void
|
||||
{
|
||||
if (!$operation instanceof Post) {
|
||||
return;
|
||||
}
|
||||
|
||||
$carrier = $address->getCarrier();
|
||||
if (!$carrier instanceof Carrier) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = $this->em->getRepository(CarrierAddress::class)->findOneBy(['carrier' => $carrier]);
|
||||
if (null !== $existing && $existing->getId() !== $address->getId()) {
|
||||
throw new ConflictHttpException('Ce transporteur a déjà une adresse.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-4.05 : si le transporteur parent est affrete (isChartered), l'adresse doit
|
||||
* porter Pays / Code postal / Ville / Adresse. Chaque champ manquant -> une
|
||||
|
||||
+19
-26
@@ -23,21 +23,21 @@ 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.
|
||||
* perimetre ERP-160. RG-4.08 (correctif, alignement M1/M2/M3) : un contact exige
|
||||
* au moins le PRENOM OU le NOM (la fonction / le telephone / l'email seuls ne
|
||||
* suffisent pas), porte a la fois par le CHECK BDD chk_carrier_contact_name et par
|
||||
* ce Processor ; le « max 2 telephones » reste une specificite M4.
|
||||
*
|
||||
* 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.
|
||||
* (max 2, chiffres uniquement), puis garde « prenom OU nom » 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
|
||||
* La garde « prenom OU nom » 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).
|
||||
*
|
||||
@@ -77,7 +77,7 @@ final class CarrierContactProcessor implements ProcessorInterface
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->normalize($data);
|
||||
$this->applyPhones($data);
|
||||
$this->validateAtLeastOneField($data);
|
||||
$this->validateName($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
@@ -187,25 +187,18 @@ final class CarrierContactProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* RG-4.08 (alignement M1/M2/M3) : un bloc Contact exige au moins le PRENOM OU le
|
||||
* NOM — un contact se materialise par son nom ; fonction / telephone / email
|
||||
* seuls ne suffisent pas. Double garde avec le CHECK BDD chk_carrier_contact_name
|
||||
* — leve une 422 propre rattachee a `firstName` plutot qu'une 500 SQL. Joue apres
|
||||
* normalisation + mapping telephones, donc les chaines vides sont deja null.
|
||||
*/
|
||||
private function validateAtLeastOneField(CarrierContact $contact): void
|
||||
private function validateName(CarrierContact $contact): void
|
||||
{
|
||||
if (
|
||||
null === $contact->getFirstName()
|
||||
&& null === $contact->getLastName()
|
||||
&& null === $contact->getJobTitle()
|
||||
&& null === $contact->getPhonePrimary()
|
||||
&& null === $contact->getEmail()
|
||||
) {
|
||||
if (null === $contact->getFirstName() && null === $contact->getLastName()) {
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Renseignez au moins un champ pour le contact.',
|
||||
'Le prénom ou le nom du contact est obligatoire.',
|
||||
null,
|
||||
[],
|
||||
$contact,
|
||||
@@ -219,8 +212,8 @@ final class CarrierContactProcessor implements ProcessorInterface
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* contrairement aux noms de personne). Evite de persister une chaine vide
|
||||
* (« » devient null) cote fonction du contact.
|
||||
*/
|
||||
private function blankToNull(?string $value): ?string
|
||||
{
|
||||
|
||||
@@ -189,12 +189,13 @@ class CarrierFixtures extends Fixture implements DependentFixtureInterface
|
||||
$address->setPostalCode($postalCode);
|
||||
$address->setCity($city);
|
||||
$address->setStreet($street);
|
||||
$carrier->addAddress($address);
|
||||
// Adresse UNIQUE (OneToOne) — ERP-172.
|
||||
$carrier->setAddress($address);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un contact normalise au transporteur (cascade persist via
|
||||
* Carrier.contacts). Au moins un champ est toujours fourni (RG-4.08).
|
||||
* Carrier.contacts). Prenom OU nom toujours fourni (RG-4.08, chk_carrier_contact_name).
|
||||
*/
|
||||
private function addContact(
|
||||
Carrier $carrier,
|
||||
|
||||
@@ -75,8 +75,12 @@ class UploadedDocument
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
// `uploaded_document:reference` : groupe minimal d'EMBARQUEMENT (nom de fichier
|
||||
// seul, sans `storedPath`/`checksum`) pour qu'une entite parente (ex: Carrier)
|
||||
// affiche le libelle du document au lieu d'un simple IRI. La parente l'ajoute a
|
||||
// son `normalizationContext`.
|
||||
#[ORM\Column(name: 'original_filename', length: 255)]
|
||||
#[Groups(['uploaded_document:read'])]
|
||||
#[Groups(['uploaded_document:read', 'uploaded_document:reference'])]
|
||||
private string $originalFilename;
|
||||
|
||||
#[ORM\Column(name: 'stored_path', length: 512)]
|
||||
|
||||
@@ -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.",
|
||||
@@ -497,23 +498,22 @@ final class ColumnCommentsCatalog
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'carrier_address' => [
|
||||
'_table' => 'Adresses d un transporteur (1:n) — onglet Adresse (M4). Pre-remplie depuis QUALIMAT si applicable (RG-4.05).',
|
||||
'_table' => 'Adresse d un transporteur (1:1, OneToOne — ERP-172 : adresse UNIQUE) — 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.',
|
||||
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE, UNIQUE (uniq_carrier_address_carrier) — transporteur proprietaire de l unique 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.',
|
||||
'_table' => 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins le prenom OU le nom rempli (RG-4.08, chk_carrier_contact_name), 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).',
|
||||
'first_name' => 'Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).',
|
||||
'last_name' => 'Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).',
|
||||
'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).',
|
||||
@@ -538,6 +538,50 @@ final class ColumnCommentsCatalog
|
||||
'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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,10 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
'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.',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Logistique\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* Endpoint `POST /api/weighbridge_readings` (§ 4.2) — tests fonctionnels.
|
||||
*
|
||||
* Couvre le wiring securite/routage (que les tests unitaires ne voient pas) :
|
||||
* - happy path AUTO / MANUAL avec site courant et permission `manage` ;
|
||||
* - 403 sans la permission `manage` (RBAC § 5.2) ;
|
||||
* - 422 si le mode est absent / invalide (validation de la ressource).
|
||||
*
|
||||
* Nettoyage manuel (pas de DAMA) : users/roles `test*` + compteurs DSD.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class WeighbridgeReadingApiTest extends AbstractApiTestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$em->getConnection()->executeStatement('DELETE FROM weighbridge_dsd_counter');
|
||||
$em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :p')
|
||||
->setParameter('p', 'testuser_%')->execute();
|
||||
$em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :p')
|
||||
->setParameter('p', 'test_%')->execute();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testAutoWeighingReturnsWeightInBoundsAndDsd(): void
|
||||
{
|
||||
$client = $this->manageClientWithCurrentSite();
|
||||
|
||||
$response = $client->request('POST', '/api/weighbridge_readings', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => ['mode' => 'AUTO'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
$data = $response->toArray();
|
||||
|
||||
self::assertSame('AUTO', $data['mode']);
|
||||
self::assertIsInt($data['weight']);
|
||||
self::assertGreaterThanOrEqual(10000, $data['weight']);
|
||||
self::assertLessThanOrEqual(50000, $data['weight']);
|
||||
self::assertIsInt($data['dsd']);
|
||||
self::assertGreaterThanOrEqual(1, $data['dsd']);
|
||||
// manualNumber est null en mode bascule (cle potentiellement omise si
|
||||
// skip_null_values est actif — tolerant aux deux cas).
|
||||
self::assertNull($data['manualNumber'] ?? null);
|
||||
}
|
||||
|
||||
public function testManualWeighingKeepsWeightAndAllocatesDsd(): void
|
||||
{
|
||||
$client = $this->manageClientWithCurrentSite();
|
||||
|
||||
$response = $client->request('POST', '/api/weighbridge_readings', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => ['mode' => 'MANUAL', 'weight' => 23187, 'manualNumber' => 'PAP-555'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
$data = $response->toArray();
|
||||
|
||||
self::assertSame('MANUAL', $data['mode']);
|
||||
self::assertSame(23187, $data['weight']);
|
||||
self::assertSame('PAP-555', $data['manualNumber']);
|
||||
self::assertGreaterThanOrEqual(1, $data['dsd']);
|
||||
}
|
||||
|
||||
public function testManagePermissionIsRequired(): void
|
||||
{
|
||||
// Un user portant uniquement `view` ne peut pas declencher de pesee.
|
||||
$credentials = $this->createUserWithPermission('logistique.weighing_tickets.view');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
|
||||
$client->request('POST', '/api/weighbridge_readings', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => ['mode' => 'AUTO'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testInvalidModeIsRejected(): void
|
||||
{
|
||||
$client = $this->manageClientWithCurrentSite();
|
||||
|
||||
$client->request('POST', '/api/weighbridge_readings', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => ['mode' => 'INVALID'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testManualWeighingRequiresWeight(): void
|
||||
{
|
||||
$client = $this->manageClientWithCurrentSite();
|
||||
|
||||
$client->request('POST', '/api/weighbridge_readings', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => ['mode' => 'MANUAL'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un user non-admin portant `logistique.weighing_tickets.manage`, lui
|
||||
* positionne un site courant (l'endpoint est cloisonne par site, § 2.3) et
|
||||
* renvoie un client authentifie.
|
||||
*/
|
||||
private function manageClientWithCurrentSite(): Client
|
||||
{
|
||||
$credentials = $this->createUserWithPermission('logistique.weighing_tickets.manage');
|
||||
|
||||
$em = $this->getEm();
|
||||
$user = $em->getRepository(User::class)->findOneBy(['username' => $credentials['username']]);
|
||||
self::assertInstanceOf(User::class, $user);
|
||||
|
||||
$site = $em->getRepository(Site::class)->findAll()[0];
|
||||
$user->setCurrentSite($site);
|
||||
$em->flush();
|
||||
|
||||
return $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Logistique\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
||||
use DateTimeImmutable;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'export XLSX des tickets de pesee (M5, § 4.5).
|
||||
* Jumeau du {@see \App\Tests\Module\Transport\Api\CarrierExportControllerTest}.
|
||||
*
|
||||
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes), mapping
|
||||
* des colonnes (numero, contrepartie, poids vide/plein/net, DSD vide/plein) avec
|
||||
* net = plein - vide, cloisonnement par site courant (un non-admin n'exporte que
|
||||
* les tickets de son site), 403 sans `logistique.weighing_tickets.view`, 401
|
||||
* anonyme.
|
||||
*
|
||||
* Nettoyage manuel (pas de DAMA) : tickets/clients de test (prefixes dedies) +
|
||||
* users/roles `test*`.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class WeighingTicketExportControllerTest extends AbstractApiTestCase
|
||||
{
|
||||
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
private const string EXPORT_URL = '/api/weighing_tickets/export.xlsx';
|
||||
private const string NUMBER_PREFIX = 'ZTEST-';
|
||||
private const string CLIENT_PREFIX = 'ZTESTWT';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$em->createQuery('DELETE FROM '.WeighingTicket::class.' wt WHERE wt.number LIKE :p')
|
||||
->setParameter('p', self::NUMBER_PREFIX.'%')->execute()
|
||||
;
|
||||
$em->createQuery('DELETE FROM '.ClientEntity::class.' c WHERE c.companyName LIKE :p')
|
||||
->setParameter('p', self::CLIENT_PREFIX.'%')->execute()
|
||||
;
|
||||
$em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :p')
|
||||
->setParameter('p', 'testuser_%')->execute()
|
||||
;
|
||||
$em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :p')
|
||||
->setParameter('p', 'test_%')->execute()
|
||||
;
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testExportReturnsXlsxResponseWithHeaders(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$this->seedTicketWithClient($this->firstSite(), 'Acme');
|
||||
|
||||
$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::assertMatchesRegularExpression('/filename="tickets-pesee-\d{8}\.xlsx"/', $disposition);
|
||||
|
||||
// 1re ligne = en-tetes attendus (ordre des colonnes § 4.5).
|
||||
$header = $this->gridFromResponse($response->getContent())[0];
|
||||
self::assertSame('Numéro', $header[0]);
|
||||
self::assertContains('Type contrepartie', $header);
|
||||
self::assertContains('Contrepartie', $header);
|
||||
self::assertContains('Date', $header);
|
||||
self::assertContains('Immatriculation', $header);
|
||||
self::assertContains('Poids vide (kg)', $header);
|
||||
self::assertContains('Poids plein (kg)', $header);
|
||||
self::assertContains('Poids net (kg)', $header);
|
||||
self::assertContains('DSD vide', $header);
|
||||
self::assertContains('DSD plein', $header);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping des colonnes : la ligne exportee porte les bonnes valeurs aux bons
|
||||
* index, et le poids net = poids plein - poids vide (RG-5.05).
|
||||
*/
|
||||
public function testExportMapsColumnsAndComputesNetWeight(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$ticket = $this->seedTicketWithClient($this->firstSite(), 'Béton SA');
|
||||
|
||||
$grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
$header = $grid[0];
|
||||
$row = $this->rowByNumber($grid, (string) $ticket->getNumber());
|
||||
self::assertNotNull($row, 'La ligne du ticket seede doit etre presente dans l\'export.');
|
||||
|
||||
$cell = static fn (string $label) => $row[array_search($label, $header, true)] ?? null;
|
||||
|
||||
self::assertSame('Client', $cell('Type contrepartie'));
|
||||
self::assertStringContainsString('BÉTON SA', (string) $cell('Contrepartie'));
|
||||
self::assertSame('AB-123-CD', $cell('Immatriculation'));
|
||||
self::assertSame(7150, (int) $cell('Poids vide (kg)'));
|
||||
self::assertSame(14300, (int) $cell('Poids plein (kg)'));
|
||||
self::assertSame(7150, (int) $cell('Poids net (kg)'));
|
||||
self::assertSame(7150, (int) $cell('Poids plein (kg)') - (int) $cell('Poids vide (kg)'));
|
||||
self::assertSame(41, (int) $cell('DSD vide'));
|
||||
self::assertSame(42, (int) $cell('DSD plein'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloisonnement par site (§ 2.3 / RG-5.09) : un non-admin (sans bypass)
|
||||
* possedant un site courant n'exporte QUE les tickets de ce site.
|
||||
*/
|
||||
public function testExportIsScopedToCurrentSiteForNonAdmin(): void
|
||||
{
|
||||
$sites = $this->getEm()->getRepository(Site::class)->findAll();
|
||||
self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites attendus (fixtures).');
|
||||
|
||||
$ticketHere = $this->seedTicketWithClient($sites[0], 'Ici');
|
||||
$ticketOther = $this->seedTicketWithClient($sites[1], 'Ailleurs');
|
||||
|
||||
$client = $this->viewClientWithCurrentSite($sites[0]);
|
||||
|
||||
$numbers = $this->numbersFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains($ticketHere->getNumber(), $numbers);
|
||||
self::assertNotContains($ticketOther->getNumber(), $numbers);
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutViewPermission(): 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);
|
||||
}
|
||||
|
||||
private function firstSite(): Site
|
||||
{
|
||||
$site = $this->getEm()->getRepository(Site::class)->findAll()[0] ?? null;
|
||||
self::assertInstanceOf(Site::class, $site, 'Un site fixture est requis.');
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un ticket complet (contrepartie Client, pesee vide + plein) rattache au
|
||||
* site donne. Numero unique prefixe pour la purge. Le net est pose
|
||||
* explicitement (pas de Processor sur un persist direct) = plein - vide.
|
||||
*/
|
||||
private function seedTicketWithClient(Site $site, string $label): WeighingTicket
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
$clientEntity = new ClientEntity();
|
||||
$clientEntity->setCompanyName(mb_strtoupper(self::CLIENT_PREFIX.' '.$label, 'UTF-8'));
|
||||
$em->persist($clientEntity);
|
||||
|
||||
$ticket = new WeighingTicket();
|
||||
$ticket->setSite($em->getReference(Site::class, $site->getId()));
|
||||
$ticket->setNumber(self::NUMBER_PREFIX.substr(bin2hex(random_bytes(5)), 0, 10));
|
||||
$ticket->setCounterpartyType('CLIENT');
|
||||
$ticket->setClient($clientEntity);
|
||||
$ticket->setImmatriculation('AB-123-CD');
|
||||
$ticket->setEmptyDate(new DateTimeImmutable('2026-06-17 09:00:00'));
|
||||
$ticket->setEmptyWeight(7150);
|
||||
$ticket->setEmptyDsd(41);
|
||||
$ticket->setEmptyMode('AUTO');
|
||||
$ticket->setFullDate(new DateTimeImmutable('2026-06-17 09:12:00'));
|
||||
$ticket->setFullWeight(14300);
|
||||
$ticket->setFullDsd(42);
|
||||
$ticket->setFullMode('AUTO');
|
||||
$ticket->setNetWeight(7150);
|
||||
|
||||
$em->persist($ticket);
|
||||
$em->flush();
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un non-admin portant `logistique.weighing_tickets.view`, lui positionne
|
||||
* un site courant (cloisonnement § 2.3) et renvoie un client authentifie.
|
||||
*/
|
||||
private function viewClientWithCurrentSite(Site $site): Client
|
||||
{
|
||||
$creds = $this->createUserWithPermission('logistique.weighing_tickets.view');
|
||||
|
||||
$em = $this->getEm();
|
||||
$user = $em->getRepository(User::class)->findOneBy(['username' => $creds['username']]);
|
||||
self::assertInstanceOf(User::class, $user);
|
||||
$user->setCurrentSite($em->getReference(Site::class, $site->getId()));
|
||||
$em->flush();
|
||||
|
||||
return $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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_wt_export_test_');
|
||||
self::assertIsString($tmp);
|
||||
file_put_contents($tmp, $binary);
|
||||
|
||||
try {
|
||||
return IOFactory::load($tmp)->getActiveSheet()->toArray();
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Premiere ligne de donnees dont la colonne « Numéro » vaut $number, ou null.
|
||||
*
|
||||
* @param array<int, array<int, mixed>> $grid
|
||||
*
|
||||
* @return null|array<int, mixed>
|
||||
*/
|
||||
private function rowByNumber(array $grid, string $number): ?array
|
||||
{
|
||||
foreach (array_slice($grid, 1) as $row) {
|
||||
if ((string) ($row[0] ?? '') === $number) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonne « Numéro » (1re colonne) des lignes de donnees.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function numbersFromResponse(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));
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
|
||||
use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface;
|
||||
use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException;
|
||||
use App\Module\Logistique\Domain\Weighbridge\WeighbridgeReading;
|
||||
use App\Module\Logistique\Infrastructure\ApiPlatform\Resource\WeighbridgeReadingResource;
|
||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighbridgeReadingProcessor;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
|
||||
|
||||
/**
|
||||
* Processor de l'action `POST /api/weighbridge_readings` (§ 4.2).
|
||||
*
|
||||
* Couvre les 4 chemins sans BDD ni HTTP (stubs purs) : AUTO (lecture pont),
|
||||
* MANUAL (allocation DSD seule), indisponibilite → 503 (RG-5.06) et absence de
|
||||
* site courant → 400.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class WeighbridgeReadingProcessorTest extends TestCase
|
||||
{
|
||||
private function site(): Site
|
||||
{
|
||||
// getId() reste null (non persiste) — sans incidence : reader et allocator
|
||||
// sont stubbes dans ces tests unitaires.
|
||||
return new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233');
|
||||
}
|
||||
|
||||
public function testAutoModeFillsWeightAndDsdFromReader(): void
|
||||
{
|
||||
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
|
||||
$siteProvider->method('get')->willReturn($this->site());
|
||||
|
||||
$reader = $this->createStub(WeighbridgeReaderInterface::class);
|
||||
$reader->method('read')->willReturn(new WeighbridgeReading(23000, 42));
|
||||
|
||||
$processor = new WeighbridgeReadingProcessor(
|
||||
$siteProvider,
|
||||
$reader,
|
||||
$this->createStub(DsdAllocatorInterface::class),
|
||||
);
|
||||
|
||||
$resource = new WeighbridgeReadingResource();
|
||||
$resource->mode = 'AUTO';
|
||||
|
||||
$result = $processor->process($resource, new Post());
|
||||
|
||||
self::assertSame(23000, $result->weight);
|
||||
self::assertSame(42, $result->dsd);
|
||||
self::assertNull($result->manualNumber);
|
||||
self::assertSame('AUTO', $result->mode);
|
||||
}
|
||||
|
||||
public function testManualModeKeepsWeightAndAllocatesDsd(): void
|
||||
{
|
||||
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
|
||||
$siteProvider->method('get')->willReturn($this->site());
|
||||
|
||||
$allocator = $this->createStub(DsdAllocatorInterface::class);
|
||||
$allocator->method('next')->willReturn(43);
|
||||
|
||||
$processor = new WeighbridgeReadingProcessor(
|
||||
$siteProvider,
|
||||
$this->createStub(WeighbridgeReaderInterface::class),
|
||||
$allocator,
|
||||
);
|
||||
|
||||
$resource = new WeighbridgeReadingResource();
|
||||
$resource->mode = 'MANUAL';
|
||||
$resource->weight = 23187;
|
||||
$resource->manualNumber = 'PAP-555';
|
||||
|
||||
$result = $processor->process($resource, new Post());
|
||||
|
||||
self::assertSame(23187, $result->weight, 'Le poids saisi est conserve en manuel.');
|
||||
self::assertSame(43, $result->dsd);
|
||||
self::assertSame('PAP-555', $result->manualNumber);
|
||||
self::assertSame('MANUAL', $result->mode);
|
||||
}
|
||||
|
||||
public function testWeighbridgeUnavailableIsMappedTo503(): void
|
||||
{
|
||||
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
|
||||
$siteProvider->method('get')->willReturn($this->site());
|
||||
|
||||
$reader = $this->createStub(WeighbridgeReaderInterface::class);
|
||||
$reader->method('read')->willThrowException(new WeighbridgeUnavailableException());
|
||||
|
||||
$processor = new WeighbridgeReadingProcessor(
|
||||
$siteProvider,
|
||||
$reader,
|
||||
$this->createStub(DsdAllocatorInterface::class),
|
||||
);
|
||||
|
||||
$resource = new WeighbridgeReadingResource();
|
||||
$resource->mode = 'AUTO';
|
||||
|
||||
try {
|
||||
$processor->process($resource, new Post());
|
||||
self::fail('Une ServiceUnavailableHttpException (503) etait attendue.');
|
||||
} catch (ServiceUnavailableHttpException $e) {
|
||||
self::assertSame(503, $e->getStatusCode());
|
||||
self::assertStringContainsString('pesée manuelle', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function testMissingCurrentSiteIsRejected(): void
|
||||
{
|
||||
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
|
||||
$siteProvider->method('get')->willReturn(null);
|
||||
|
||||
$processor = new WeighbridgeReadingProcessor(
|
||||
$siteProvider,
|
||||
$this->createStub(WeighbridgeReaderInterface::class),
|
||||
$this->createStub(DsdAllocatorInterface::class),
|
||||
);
|
||||
|
||||
$resource = new WeighbridgeReadingResource();
|
||||
$resource->mode = 'AUTO';
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$processor->process($resource, new Post());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Logistique\Infrastructure\Service;
|
||||
|
||||
use App\Module\Logistique\Infrastructure\Service\DsdAllocator;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* Allocateur DSD (RG-5.04 / § 2.7) — test d'integration sur la table
|
||||
* `weighbridge_dsd_counter` (DBAL brut, verrou FOR UPDATE).
|
||||
*
|
||||
* Verifie l'increment sequentiel et l'isolation PAR SITE (un pont par site).
|
||||
* Les compteurs des sites touches sont remis a zero en debut de test et purges
|
||||
* en tearDown (pas de DAMA en local — nettoyage manuel obligatoire).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class DsdAllocatorTest extends KernelTestCase
|
||||
{
|
||||
private Connection $connection;
|
||||
private DsdAllocator $allocator;
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
/** @var list<int> */
|
||||
private array $touchedSiteIds = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$container = self::getContainer();
|
||||
$this->em = $container->get('doctrine')->getManager();
|
||||
$this->connection = $this->em->getConnection();
|
||||
$this->allocator = $container->get(DsdAllocator::class);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ([] !== $this->touchedSiteIds) {
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM weighbridge_dsd_counter WHERE site_id IN (?)',
|
||||
[$this->touchedSiteIds],
|
||||
[ArrayParameterType::INTEGER],
|
||||
);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testNextIncrementsSequentiallyAndIsIsolatedPerSite(): void
|
||||
{
|
||||
$sites = $this->em->getRepository(Site::class)->findAll();
|
||||
self::assertGreaterThanOrEqual(2, \count($sites), 'Au moins 2 sites doivent etre seedes (fixtures).');
|
||||
|
||||
$siteA = $sites[0];
|
||||
$siteB = $sites[1];
|
||||
$this->resetCounter($siteA);
|
||||
$this->resetCounter($siteB);
|
||||
|
||||
// AUTO/MANUAL partagent le meme increment : la sequence demarre a 1.
|
||||
self::assertSame(1, $this->allocator->next($siteA));
|
||||
self::assertSame(2, $this->allocator->next($siteA));
|
||||
self::assertSame(3, $this->allocator->next($siteA));
|
||||
|
||||
// Isolation par site : le compteur de B est independant de celui de A.
|
||||
self::assertSame(1, $this->allocator->next($siteB));
|
||||
self::assertSame(2, $this->allocator->next($siteB));
|
||||
|
||||
// La sequence de A reprend la ou elle en etait (4), non perturbee par B.
|
||||
self::assertSame(4, $this->allocator->next($siteA));
|
||||
}
|
||||
|
||||
public function testNextStartsAtOneWhenNoCounterRowExists(): void
|
||||
{
|
||||
$site = $this->em->getRepository(Site::class)->findAll()[0];
|
||||
$this->resetCounter($site);
|
||||
|
||||
// Aucune ligne compteur pour ce site : le premier appel la cree (last=0)
|
||||
// et renvoie 1 (dernier + 1).
|
||||
self::assertSame(1, $this->allocator->next($site));
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime la ligne compteur du site pour repartir d'un etat connu, et
|
||||
* enregistre l'id pour la purge de tearDown.
|
||||
*/
|
||||
private function resetCounter(Site $site): void
|
||||
{
|
||||
$siteId = $site->getId();
|
||||
self::assertNotNull($siteId);
|
||||
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM weighbridge_dsd_counter WHERE site_id = :site',
|
||||
['site' => $siteId],
|
||||
);
|
||||
|
||||
if (!\in_array($siteId, $this->touchedSiteIds, true)) {
|
||||
$this->touchedSiteIds[] = $siteId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Logistique\Infrastructure\Weighbridge;
|
||||
|
||||
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
|
||||
use App\Module\Logistique\Infrastructure\Weighbridge\RandomWeighbridgeReader;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Stub du pont bascule (RG-5.06 / § 2.6).
|
||||
*
|
||||
* Verifie le contrat du stub livre au M5 : poids aleatoire borne a
|
||||
* [10000, 50000] kg et DSD delegue a l'allocateur (le chemin d'erreur 503
|
||||
* est couvert cote Processor — WeighbridgeReadingProcessorTest).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class WeighbridgeReaderStubTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* RG-5.06 : sur un grand nombre de lectures, le poids reste toujours dans
|
||||
* l'intervalle borne [10000, 50000] (random_int inclusif aux deux bornes).
|
||||
*/
|
||||
public function testReadReturnsWeightWithinBounds(): void
|
||||
{
|
||||
$allocator = $this->createStub(DsdAllocatorInterface::class);
|
||||
$allocator->method('next')->willReturn(1);
|
||||
|
||||
$reader = new RandomWeighbridgeReader($allocator);
|
||||
$site = $this->createStub(SiteInterface::class);
|
||||
|
||||
for ($i = 0; $i < 500; ++$i) {
|
||||
$reading = $reader->read($site);
|
||||
self::assertGreaterThanOrEqual(10000, $reading->weight);
|
||||
self::assertLessThanOrEqual(50000, $reading->weight);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-5.04 : le DSD renvoye par la lecture est celui fourni par l'allocateur
|
||||
* de site (le reader ne calcule pas le DSD lui-meme).
|
||||
*/
|
||||
public function testReadDelegatesDsdToAllocator(): void
|
||||
{
|
||||
$allocator = $this->createStub(DsdAllocatorInterface::class);
|
||||
$allocator->method('next')->willReturn(42);
|
||||
|
||||
$reader = new RandomWeighbridgeReader($allocator);
|
||||
$reading = $reader->read($this->createStub(SiteInterface::class));
|
||||
|
||||
self::assertSame(42, $reading->dsd);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -149,7 +149,7 @@ abstract class AbstractCarrierApiTestCase extends AbstractApiTestCase
|
||||
$address->setPostalCode('86000');
|
||||
$address->setCity('Poitiers');
|
||||
$address->setStreet('12 rue des Acacias');
|
||||
$carrier->addAddress($address);
|
||||
$carrier->setAddress($address);
|
||||
$em->persist($address);
|
||||
|
||||
$contact = new CarrierContact();
|
||||
|
||||
@@ -13,7 +13,7 @@ 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}.
|
||||
* POST /api/carriers/{id}/address, PATCH/DELETE /api/carrier_addresses/{id}.
|
||||
*
|
||||
* Contrat verifie :
|
||||
* - RG-4.06 : code postal hors ^[0-9]{4,5}$ -> 422 ;
|
||||
@@ -55,7 +55,7 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
||||
$carrier = $this->seedCarrierWithChartered('Cp Invalide', false);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/address', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '123'], // 3 chiffres -> Regex KO
|
||||
]);
|
||||
@@ -73,7 +73,7 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
||||
$carrier = $this->seedCarrierWithChartered('Cp Ville Incoherents', false);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/address', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86000', // Poitiers
|
||||
@@ -91,7 +91,7 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
||||
$carrier = $this->seedCarrierWithChartered('Affrete Incomplet', true);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/address', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '86000'],
|
||||
]);
|
||||
@@ -107,7 +107,7 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
||||
$carrier = $this->seedCarrierWithChartered('Affrete Complet', true);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/address', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'country' => 'France',
|
||||
@@ -119,13 +119,30 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testSecondAddressReturns409(): void
|
||||
{
|
||||
// Adresse UNIQUE (ERP-172) : un 2e POST sur un transporteur qui a deja une
|
||||
// adresse -> 409 explicite (garde CarrierAddressProcessor avant la contrainte
|
||||
// d'unicite carrier_id).
|
||||
$address = $this->seedAddress('Deja Une Adresse', false);
|
||||
$carrier = $address->getCarrier();
|
||||
self::assertNotNull($carrier);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/address', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '17000', 'city' => 'La Rochelle', 'street' => '2 rue Neuve'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
}
|
||||
|
||||
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', [
|
||||
$client->request('POST', '/api/carriers/999999/address', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '86000', 'city' => 'Poitiers', 'street' => '1 rue X'],
|
||||
]);
|
||||
@@ -156,7 +173,7 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
||||
self::assertNotNull($carrier);
|
||||
$client = $this->authenticatedClient('commerciale', self::PWD); // view seul
|
||||
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/address', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['postalCode' => '86000', 'city' => 'Poitiers', 'street' => '1 rue X'],
|
||||
]);
|
||||
@@ -201,7 +218,7 @@ final class CarrierAddressApiTest extends AbstractCarrierApiTestCase
|
||||
$address->setPostalCode('86000');
|
||||
$address->setCity('Poitiers');
|
||||
$address->setStreet('12 rue des Acacias');
|
||||
$carrier->addAddress($address);
|
||||
$carrier->setAddress($address);
|
||||
$em->persist($address);
|
||||
$em->flush();
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ use Symfony\Component\Console\Output\NullOutput;
|
||||
* 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 : contact sans prenom ni nom -> 422 (alignement M1/M2/M3) ;
|
||||
* - RG-4.08 : un nom (ou prenom) suffit -> 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).
|
||||
@@ -51,7 +51,8 @@ final class CarrierContactApiTest extends AbstractCarrierApiTestCase
|
||||
|
||||
public function testEmptyContactReturns422(): void
|
||||
{
|
||||
// RG-4.08 : aucun champ rempli -> 422 (garde Processor, double du CHECK BDD).
|
||||
// RG-4.08 (alignement M1/M2/M3) : sans prenom ni nom -> 422 (garde Processor,
|
||||
// double du CHECK BDD chk_carrier_contact_name).
|
||||
$carrier = $this->seedCarrier('Contact Vide');
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
@@ -60,13 +61,13 @@ final class CarrierContactApiTest extends AbstractCarrierApiTestCase
|
||||
'json' => [],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
// RG-4.08 : la violation est rattachee a `firstName` (mapping inline ERP-101).
|
||||
// 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.
|
||||
// RG-4.08 : un nom (ou prenom) suffit a valider le bloc.
|
||||
$carrier = $this->seedCarrier('Contact Mono');
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
|
||||
@@ -4,9 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Transport\Api;
|
||||
|
||||
use App\Module\Transport\Domain\Entity\Carrier;
|
||||
use App\Shared\Domain\Entity\UploadedDocument;
|
||||
use App\Tests\Module\Commercial\Api\SupplierSerializationContractTest;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* 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}.
|
||||
* § 4.0 / § 4.0.bis). Jumeau de {@see 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.
|
||||
@@ -88,8 +93,9 @@ final class CarrierSerializationContractTest extends AbstractCarrierApiTestCase
|
||||
self::assertArrayHasKey('isChartered', $data);
|
||||
self::assertFalse($data['isArchived']);
|
||||
|
||||
self::assertNotEmpty($data['addresses']);
|
||||
self::assertSame('Poitiers', $data['addresses'][0]['city']);
|
||||
// Adresse UNIQUE (OneToOne, ERP-172) : embarquee en OBJET (pas une liste).
|
||||
self::assertIsArray($data['address']);
|
||||
self::assertSame('Poitiers', $data['address']['city']);
|
||||
|
||||
self::assertNotEmpty($data['contacts']);
|
||||
self::assertSame('Marie', $data['contacts'][0]['firstName']);
|
||||
@@ -133,6 +139,43 @@ final class CarrierSerializationContractTest extends AbstractCarrierApiTestCase
|
||||
self::assertIsArray($supplierPrice['deliverySite']);
|
||||
}
|
||||
|
||||
// === Decharge (RG-4.02) embarquee en OBJET avec son nom de fichier (ERP-171) ===
|
||||
|
||||
public function testDetailEmbedsDischargeDocumentFilename(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
// Decharge (UploadedDocument) rattachee a un transporteur certifie AUTRE.
|
||||
$document = new UploadedDocument(
|
||||
originalFilename: 'decharge-test.pdf',
|
||||
storedPath: '2026/06/'.bin2hex(random_bytes(8)).'.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
sizeBytes: 1234,
|
||||
checksum: hash('sha256', 'contenu'),
|
||||
createdAt: new DateTimeImmutable(),
|
||||
);
|
||||
$em->persist($document);
|
||||
|
||||
$carrier = new Carrier();
|
||||
$carrier->setName('AUTRE DISCHARGE CO');
|
||||
$carrier->setCertificationType('AUTRE');
|
||||
$carrier->setDischargeDocument($document);
|
||||
$em->persist($carrier);
|
||||
$em->flush();
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/carriers/'.$carrier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// dischargeDocument embarque en OBJET (uploaded_document:reference) avec son
|
||||
// nom de fichier — sinon le front n'a qu'un IRI nu et affiche un champ vide.
|
||||
self::assertArrayHasKey('dischargeDocument', $data);
|
||||
self::assertIsArray($data['dischargeDocument'], 'dischargeDocument doit etre un objet embarque, pas un IRI nu.');
|
||||
self::assertSame('decharge-test.pdf', $data['dischargeDocument']['originalFilename']);
|
||||
// Le groupe minimal n'expose PAS les metadonnees internes (storedPath / checksum).
|
||||
self::assertArrayNotHasKey('storedPath', $data['dischargeDocument']);
|
||||
self::assertArrayNotHasKey('checksum', $data['dischargeDocument']);
|
||||
}
|
||||
|
||||
// === RBAC : 403 sans la permission view ===
|
||||
|
||||
public function testForbiddenWithoutViewPermission(): void
|
||||
@@ -167,7 +210,7 @@ final class CarrierSerializationContractTest extends AbstractCarrierApiTestCase
|
||||
|
||||
self::assertArrayHasKey('member', $list);
|
||||
self::assertArrayHasKey('qualimatCarrier', $detail);
|
||||
self::assertArrayHasKey('addresses', $detail);
|
||||
self::assertArrayHasKey('address', $detail);
|
||||
self::assertArrayHasKey('contacts', $detail);
|
||||
self::assertArrayHasKey('prices', $detail);
|
||||
|
||||
@@ -183,7 +226,7 @@ final class CarrierSerializationContractTest extends AbstractCarrierApiTestCase
|
||||
*
|
||||
* @param array<string, mixed> $collection
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
* @return null|array<string, mixed>
|
||||
*/
|
||||
private function memberById(array $collection, int $id): ?array
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user