Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f3fe4da4e | |||
| ef7bf69980 | |||
| 117dcdbdcc | |||
| a4158d4e37 | |||
| 5e15c1f69f | |||
| 6c938756cc | |||
| ac1a51a7f4 | |||
| a9935fbf97 | |||
| 36e947fd8e | |||
| b4e550b5de | |||
| 10c113dbad | |||
| c0dadd79ff | |||
| 1ffa38282a | |||
| 036b075d5e | |||
| 25466b18d8 | |||
| 02a22597b3 | |||
| 76e7a59ba7 | |||
| e88bb059e6 | |||
| 312c119c06 | |||
| 8491f55072 |
@@ -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
|
||||
|
||||
+21
-20
@@ -38,7 +38,27 @@ declare(strict_types=1);
|
||||
*/
|
||||
|
||||
return [
|
||||
// Section "Commerciale" : pole metier principal, remontee en tete de sidebar (ERP-71).
|
||||
// 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.
|
||||
// Placee en tete de sidebar (avant Commerciale). 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:truck-outline',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.logistique.weighing_tickets',
|
||||
'to' => '/weighing-tickets',
|
||||
'icon' => 'mdi:truck-outline',
|
||||
'module' => 'logistique',
|
||||
'permission' => 'logistique.weighing_tickets.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Commerciale" : pole metier principal (ERP-71).
|
||||
// L'ordre interne des onglets et les permissions restent inchanges (simple deplacement
|
||||
// du bloc, aucun gate touche).
|
||||
[
|
||||
@@ -78,25 +98,6 @@ 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.138'
|
||||
app.version: '0.1.147'
|
||||
|
||||
@@ -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,790 @@
|
||||
---
|
||||
# === 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 Bon de pesée — PDF généré côté serveur via template Twig (RG-5.08)
|
||||
|
||||
> **DÉCISION Matthieu (17/06)** : le **bon de pesée est généré côté back** par un **template Twig → PDF** (et non un gabarit imprimé par le navigateur). **OWNER : Tristan** (ticket back dédié, cf. § 10). Cette spec en pose le contrat (endpoint, contenu, données).
|
||||
|
||||
Contrat attendu :
|
||||
- **Endpoint** : `GET /api/weighing_tickets/{id}/print.pdf` (opération API Platform dédiée, **pas de controller** — provider renvoyant un binaire). Sécurité `is_granted('logistique.weighing_tickets.view')`. Réponse `Content-Type: application/pdf` (inline).
|
||||
- **Rendu** : un template **Twig** (`templates/logistique/weighing_ticket_print.html.twig`) hydraté avec le ticket → converti en PDF via le générateur PDF du projet (ex. Dompdf / wkhtmltopdf / Gotenberg — s'aligner sur l'existant ; sinon proposer une lib et la cadrer avec Matthieu).
|
||||
- **Contenu 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. (En-tête / logo / mentions = à caler par Tristan.)
|
||||
- **Données** : toutes déjà disponibles sur le ticket (mêmes champs que `GET /api/weighing_tickets/{id}` § 4.0) — aucun champ API supplémentaire requis.
|
||||
- **Déclencheurs front** (RG-5.08) : à la **validation** (création), le front ouvre l'aperçu/PDF servi par cet endpoint ; en **modification**, le bouton **« Imprimer »** ouvre le même PDF (absent à l'ajout).
|
||||
|
||||
### 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 — CAPTURÉE sur l'API réelle ✅)
|
||||
|
||||
> **Definition of Done** (miroir M2/M3/M4) : ✅ **FAIT (ERP-187)**. Le JSON ci-dessous est la réponse **RÉELLE** capturée par le test `WeighingTicketSerializationContractTest::testListAndDetailSerializationContract` (ticket créé via `POST /api/weighing_tickets` — numérotation serveur réelle — contrepartie Client, pesée vide + plein AUTO). Re-capturable : `WEIGHING_TICKET_DOD_DUMP=1` → `/tmp/weighing-ticket-dod-{list,detail}.json`. **Feu vert front.** Toute donnée affichée par le front DOIT apparaître dans ce JSON.
|
||||
>
|
||||
> **Pièges re-testés — tous VERTS** (assertions dans le test) :
|
||||
> 1. ✅ `client` sort en **objet embarqué** (`client:read`), pas en IRI nu ; `supplier` **omis car null** (`skip_null_values` — jamais un IRI nu). Sur une contrepartie Fournisseur, `supplier` sortirait symétriquement en objet (`supplier:read`).
|
||||
> 2. ✅ Booléen `plateFreeFormat` : **clé présente** (getter `isPlateFreeFormat()` + `SerializedName('plateFreeFormat')`).
|
||||
> 3. ✅ `number` présent et formaté `{siteCode}-TP-{NNNN}` (ici `86-TP-0001`).
|
||||
> 4. ✅ `netWeight` cohérent = `full - empty` = `14300 - 7150` = **`7150`** (RG-5.05).
|
||||
>
|
||||
> **Note `skip_null_values`** : les champs null sont **omis** du JSON (ex. `supplier`, `otherLabel`, `emptyManualNumber`, `fullManualNumber` absents quand null). Le front ne doit pas présumer leur présence — lire avec un défaut (`?? null`).
|
||||
|
||||
**`GET /api/weighing_tickets?search=86-TP-0001` (LISTE)** — enveloppe Hydra AP4 (`member`/`totalItems`/`view`), filtrée site courant (§ 2.3). Capture réelle :
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"@context": "/api/contexts/WeighingTicket",
|
||||
"@id": "/api/weighing_tickets",
|
||||
"@type": "Collection",
|
||||
"totalItems": 1,
|
||||
"member": [
|
||||
{
|
||||
"@id": "/api/weighing_tickets/9",
|
||||
"@type": "WeighingTicket",
|
||||
"id": 9,
|
||||
"number": "86-TP-0001",
|
||||
"counterpartyType": "CLIENT",
|
||||
"client": {
|
||||
"@id": "/api/clients/629",
|
||||
"@type": "Client",
|
||||
"id": 629,
|
||||
"companyName": "NÉGOCE MÉTAUX ATLANTIQUE",
|
||||
"triageService": false,
|
||||
"categories": [],
|
||||
"createdAt": "2026-06-18T11:50:47+02:00",
|
||||
"updatedAt": "2026-06-18T11:50:47+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me",
|
||||
"sites": [],
|
||||
"isArchived": false
|
||||
},
|
||||
"plateFreeFormat": false,
|
||||
"netWeight": 7150,
|
||||
"createdAt": "2026-06-18T11:50:48+02:00",
|
||||
"updatedAt": "2026-06-18T11:50:48+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me",
|
||||
"displayDate": "2026-06-17T09:12:00+02:00"
|
||||
// supplier / otherLabel omis (null → skip_null_values)
|
||||
}
|
||||
],
|
||||
"view": { "@id": "/api/weighing_tickets?search=86-TP-0001", "@type": "PartialCollectionView" }
|
||||
}
|
||||
```
|
||||
|
||||
**`GET /api/weighing_tickets/9` (DÉTAIL)** — ajoute le site embarqué (avec `code`), l'immatriculation et les deux pesées. Capture réelle :
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"@context": "/api/contexts/WeighingTicket",
|
||||
"@id": "/api/weighing_tickets/9",
|
||||
"@type": "WeighingTicket",
|
||||
"id": 9,
|
||||
"number": "86-TP-0001",
|
||||
"site": {
|
||||
"@id": "/api/sites/1",
|
||||
"@type": "Site",
|
||||
"id": 1,
|
||||
"name": "Chatellerault",
|
||||
"code": "86",
|
||||
"street": "14 All. d'Argenson",
|
||||
"postalCode": "86100",
|
||||
"city": "Châtellerault",
|
||||
"color": "#056CF2",
|
||||
"createdAt": "2026-06-17T17:07:47+02:00",
|
||||
"updatedAt": "2026-06-17T17:07:47+02:00",
|
||||
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
|
||||
},
|
||||
"counterpartyType": "CLIENT",
|
||||
"client": {
|
||||
"@id": "/api/clients/629",
|
||||
"@type": "Client",
|
||||
"id": 629,
|
||||
"companyName": "NÉGOCE MÉTAUX ATLANTIQUE",
|
||||
"triageService": false,
|
||||
"categories": [],
|
||||
"createdAt": "2026-06-18T11:50:47+02:00",
|
||||
"updatedAt": "2026-06-18T11:50:47+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me",
|
||||
"sites": [],
|
||||
"isArchived": false
|
||||
},
|
||||
"immatriculation": "AB-123-CD",
|
||||
"plateFreeFormat": false,
|
||||
"emptyDate": "2026-06-17T09:00:00+02:00",
|
||||
"emptyWeight": 7150,
|
||||
"emptyDsd": 1,
|
||||
"emptyMode": "AUTO",
|
||||
"fullDate": "2026-06-17T09:12:00+02:00",
|
||||
"fullWeight": 14300,
|
||||
"fullDsd": 2,
|
||||
"fullMode": "AUTO",
|
||||
"netWeight": 7150,
|
||||
"createdAt": "2026-06-18T11:50:48+02:00",
|
||||
"updatedAt": "2026-06-18T11:50:48+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me",
|
||||
"displayDate": "2026-06-17T09:12:00+02:00"
|
||||
// emptyManualNumber / fullManualNumber omis (null → skip_null_values)
|
||||
}
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
### 4.6 Impression — `GET /api/weighing_tickets/{id}/print.pdf` (bon de pesée, OWNER Tristan)
|
||||
|
||||
- Opération API Platform dédiée (provider renvoyant un binaire PDF, **pas de controller**). Sécurité `is_granted('logistique.weighing_tickets.view')`.
|
||||
- Rendu d'un **template Twig** (`templates/logistique/weighing_ticket_print.html.twig`) → PDF (cf. § 2.12). `Content-Type: application/pdf`, inline.
|
||||
- Contenu : cf. § 2.12. Données déjà portées par le ticket — aucun champ API supplémentaire.
|
||||
|
||||
## 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 le **bon de pesée (PDF servi par le back)**. En modification : bouton « Valider » → « Enregistrer », bouton « Imprimer » disponible (absent à l'ajout) → ouvre le même PDF. Le bouton « Enregistré » du bloc pesée à vide disparaît en modification. **Bon de pesée = PDF généré back via template Twig, OWNER Tristan** (§ 2.12 / § 4.6). |
|
||||
| **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~~ | **Passé en périmètre** : bon de pesée = PDF serveur via template Twig → ticket back dédié (OWNER Tristan, § 2.12 / § 4.6). |
|
||||
| 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 |
|
||||
| 6.bis (ERP-192) | **Bon de pesée — PDF via template Twig** (`/print.pdf`, § 2.12 / § 4.6) | **Backend (OWNER Tristan)** |
|
||||
| 7 | Page liste `/weighing-tickets` (usePaginatedList) + export | Frontend |
|
||||
| 8 | Écran Ajouter (formulaires vide + plein, pesée bascule/manuelle, masque immat) + ouverture PDF à la validation | Frontend |
|
||||
| 9 | Écran Modification + bouton « Imprimer » (ouvre le PDF back) | Frontend |
|
||||
| 10 | i18n + libellé audit + branchement site courant | Frontend |
|
||||
@@ -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 |
|
||||
@@ -71,7 +71,7 @@
|
||||
"companyName": "Nom",
|
||||
"categories": "Catégories",
|
||||
"sites": "Site",
|
||||
"lastActivity": "Dernière activité"
|
||||
"lastActivity": "Dernière modification"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
@@ -125,7 +125,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le fournisseur",
|
||||
"back": "Retour au répertoire",
|
||||
"back": "Retour à la consultation",
|
||||
"loading": "Chargement du fournisseur…",
|
||||
"notFound": "Fournisseur introuvable.",
|
||||
"save": "Enregistrer"
|
||||
@@ -215,7 +215,7 @@
|
||||
"companyName": "Nom",
|
||||
"categories": "Catégories",
|
||||
"sites": "Site",
|
||||
"lastActivity": "Dernière activité"
|
||||
"lastActivity": "Dernière modification"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
@@ -268,7 +268,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le client",
|
||||
"back": "Retour au répertoire",
|
||||
"back": "Retour à la consultation",
|
||||
"loading": "Chargement du client…",
|
||||
"notFound": "Client introuvable.",
|
||||
"save": "Enregistrer"
|
||||
@@ -385,7 +385,7 @@
|
||||
"companyName": "Nom",
|
||||
"categories": "Catégories",
|
||||
"sites": "Site",
|
||||
"lastActivity": "Dernière activité"
|
||||
"lastActivity": "Dernière modification"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
@@ -420,7 +420,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le prestataire",
|
||||
"back": "Retour à la fiche",
|
||||
"back": "Retour à la consultation",
|
||||
"loading": "Chargement…",
|
||||
"notFound": "Prestataire introuvable.",
|
||||
"save": "Enregistrer"
|
||||
@@ -453,7 +453,6 @@
|
||||
},
|
||||
"address": {
|
||||
"sites": "Sites",
|
||||
"categories": "Catégorie",
|
||||
"contacts": "Contact(s) rattaché(s)",
|
||||
"country": "Pays",
|
||||
"postalCode": "Code postal",
|
||||
@@ -509,7 +508,7 @@
|
||||
"name": "Nom",
|
||||
"certification": "Certification",
|
||||
"validityDate": "Date de validité",
|
||||
"lastActivity": "Dernière activité"
|
||||
"lastActivity": "Dernière modification"
|
||||
},
|
||||
"certification": {
|
||||
"QUALIMAT": "QUALIMAT",
|
||||
@@ -558,8 +557,8 @@
|
||||
"message": "Ce transporteur réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
|
||||
},
|
||||
"price": {
|
||||
"group": "Type de transport",
|
||||
"carrier": "Transporteurs",
|
||||
"group": "Transport",
|
||||
"carrier": "Fournisseurs / Clients",
|
||||
"aproOrSite": "Adresse sites",
|
||||
"delivery": "Adresse livraisons",
|
||||
"forfait": "Forfait (€)",
|
||||
@@ -692,6 +691,63 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"logistique": {
|
||||
"weighingTickets": {
|
||||
"title": "Tickets de pesée",
|
||||
"add": "Ajouter",
|
||||
"export": "Exporter",
|
||||
"empty": "Aucun ticket de pesée pour l'instant.",
|
||||
"column": {
|
||||
"number": "Numéro",
|
||||
"client": "Client",
|
||||
"supplier": "Fournisseur",
|
||||
"other": "Autre",
|
||||
"date": "Date",
|
||||
"weight": "Poids"
|
||||
},
|
||||
"form": {
|
||||
"back": "Retour à la liste",
|
||||
"addTitle": "Ajouter un ticket de pesée",
|
||||
"emptyBlock": "Poids à vide",
|
||||
"fullBlock": "Poids à plein",
|
||||
"date": "Date",
|
||||
"weight": "Poids (Kg)",
|
||||
"dsd": "DSD",
|
||||
"immatriculation": "Immatriculation",
|
||||
"plateFreeFormat": "Tout format",
|
||||
"save": "Enregistrer",
|
||||
"validate": "Valider",
|
||||
"counterparty": {
|
||||
"type": "Fournisseur / Client / Autre",
|
||||
"supplier": "Fournisseur",
|
||||
"client": "Client",
|
||||
"other": "Autre"
|
||||
},
|
||||
"weighbridge": {
|
||||
"auto": "Pesée bascule",
|
||||
"manual": "Pesée manuelle",
|
||||
"confirmTitle": "Pesée bascule",
|
||||
"confirmMessage": "Êtes-vous sûr de vouloir déclencher une pesée ?",
|
||||
"cancel": "Annuler",
|
||||
"validate": "Valider",
|
||||
"unavailable": "Pont bascule indisponible — passez en pesée manuelle."
|
||||
},
|
||||
"manual": {
|
||||
"title": "Pesée manuelle",
|
||||
"weight": "Poids (Kg)",
|
||||
"number": "Numéro de pesée",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"weightRequired": "Le poids est obligatoire.",
|
||||
"numberRequired": "Le numéro de pesée est obligatoire."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"error": "Une erreur est survenue. Réessayez.",
|
||||
"exportError": "L'export des tickets de pesée a échoué. Réessayez."
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"login": "Connexion",
|
||||
"logout": "Deconnexion",
|
||||
@@ -754,7 +810,8 @@
|
||||
"transport_carrier": "Transporteur",
|
||||
"transport_carrieraddress": "Adresse transporteur",
|
||||
"transport_carriercontact": "Contact transporteur",
|
||||
"transport_carrierprice": "Prix transporteur"
|
||||
"transport_carrierprice": "Prix transporteur",
|
||||
"logistique_weighingticket": "Ticket de pesée"
|
||||
},
|
||||
"empty": "Aucune activité enregistrée",
|
||||
"no_results": "Aucun résultat pour ces filtres",
|
||||
@@ -789,7 +846,8 @@
|
||||
"auth": {
|
||||
"logout": "Deconnexion reussie"
|
||||
},
|
||||
"title": "Succès"
|
||||
"title": "Succès",
|
||||
"deleted": "Suppression effectuée"
|
||||
},
|
||||
"admin": {
|
||||
"roles": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<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)]">
|
||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -21,7 +21,8 @@
|
||||
:options="addressTypeOptions"
|
||||
:label="t('commercial.clients.form.address.addressType')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.isProspect"
|
||||
@update:model-value="onAddressTypeChange"
|
||||
/>
|
||||
@@ -33,17 +34,21 @@
|
||||
:label="t('commercial.clients.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif). Consultation : masque si aucun (ERP-193). -->
|
||||
<MalioSelectCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.contactIris)"
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('commercial.clients.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
@@ -55,8 +60,9 @@
|
||||
v-if="isBillingEmailRequired(model)"
|
||||
:model-value="model.billingEmail"
|
||||
:label="t('commercial.clients.form.address.billingEmail')"
|
||||
:required="true"
|
||||
:required="!readonly && !disabled"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.billingEmail"
|
||||
:addable="!model.hasSecondaryBillingEmail && !readonly"
|
||||
@@ -64,13 +70,17 @@
|
||||
@update:model-value="(v: string) => update('billingEmail', v)"
|
||||
@add="revealSecondaryBillingEmail"
|
||||
/>
|
||||
<div v-else aria-hidden="true" />
|
||||
<!-- Filler : aligne la suite de la grille (Categorie au debut de ligne).
|
||||
Inutile en consultation masquee (la grille se recompose sans les
|
||||
champs vides, ERP-193). -->
|
||||
<div v-else-if="!hideEmpty" aria-hidden="true" />
|
||||
|
||||
<MalioInputEmail
|
||||
v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail"
|
||||
:model-value="model.billingEmailSecondary"
|
||||
:label="t('commercial.clients.form.address.billingEmailSecondary')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.billingEmailSecondary"
|
||||
@update:model-value="(v: string) => update('billingEmailSecondary', v)"
|
||||
@@ -82,7 +92,8 @@
|
||||
:label="t('commercial.clients.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
@@ -92,7 +103,8 @@
|
||||
:options="countryOptions"
|
||||
:label="t('commercial.clients.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
@@ -101,7 +113,8 @@
|
||||
:label="t('commercial.clients.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
@@ -115,17 +128,20 @@
|
||||
:options="cityOptions"
|
||||
:label="t('commercial.clients.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.city"
|
||||
:label="t('commercial.clients.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
@@ -142,14 +158,15 @@
|
||||
blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste
|
||||
pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. -->
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly"
|
||||
v-if="!readonly && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('commercial.clients.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('commercial.clients.form.address.streetNotFound')"
|
||||
@@ -162,17 +179,20 @@
|
||||
:model-value="model.street"
|
||||
:label="t('commercial.clients.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1">
|
||||
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('commercial.clients.form.address.streetComplement')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
@@ -191,6 +211,8 @@ import {
|
||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
|
||||
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque code postal FR : 5 chiffres.
|
||||
const POSTAL_CODE_MASK = '#####'
|
||||
@@ -209,6 +231,10 @@ const props = defineProps<{
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -284,11 +310,37 @@ const addressLoading = ref(false)
|
||||
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||
let lastAddressSuggestions: AddressSuggestion[] = []
|
||||
|
||||
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur
|
||||
// les champs texte editables (complement, ville en mode degrade). La voie en
|
||||
// autocomplete (BAN) et la ville en select ne sont pas masquees (le back valide
|
||||
// via Assert\Regex) ; les emails de facturation valident leur format (Assert\Email).
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
|
||||
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
|
||||
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
|
||||
* a chaque frappe).
|
||||
*/
|
||||
function onCityChange(value: string | number | null): void {
|
||||
const next = value === null ? null : String(value)
|
||||
if (next === (props.modelValue.city ?? null)) {
|
||||
return
|
||||
}
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
city: next,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** Revele le 2e champ email de facturation (clic sur le « + »). */
|
||||
function revealSecondaryBillingEmail(): void {
|
||||
emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true })
|
||||
@@ -304,9 +356,27 @@ function notifyUnavailable(): void {
|
||||
|
||||
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
|
||||
async function onPostalCodeChange(value: string): Promise<void> {
|
||||
update('postalCode', value)
|
||||
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
|
||||
|
||||
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
|
||||
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
|
||||
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
|
||||
if (digits.length === 5 && digits !== previousDigits) {
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
postalCode: value,
|
||||
city: null,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
else {
|
||||
update('postalCode', value)
|
||||
}
|
||||
|
||||
if (digits.length < 5) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
|
||||
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -13,44 +13,56 @@
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||
:model-value="model.lastName"
|
||||
:label="t('commercial.clients.form.contact.lastName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||
:model-value="model.firstName"
|
||||
:label="t('commercial.clients.form.contact.firstName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div class="col-span-2">
|
||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('commercial.clients.form.contact.jobTitle')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
v-if="!hideEmpty || isFilled(model.email)"
|
||||
:model-value="model.email"
|
||||
:label="t('commercial.clients.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('commercial.clients.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('commercial.clients.form.contact.addPhone')"
|
||||
@@ -58,11 +70,12 @@
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone"
|
||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('commercial.clients.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
@@ -71,6 +84,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ContactFormDraft } from '~/modules/commercial/types/clientForm'
|
||||
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste
|
||||
// serveur, cf. formatPhoneFR re-applique a la valeur renvoyee).
|
||||
@@ -85,6 +100,10 @@ const props = defineProps<{
|
||||
removable?: boolean
|
||||
/** Bloc en lecture seule (onglet valide). */
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -99,6 +118,10 @@ const { t } = useI18n()
|
||||
// Alias local pour la lisibilite du template.
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
// Filtrage des caracteres parasites : porte par les masks maska sur les champs
|
||||
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
|
||||
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof ContactFormDraft>(field: K, value: ContactFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<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"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -18,8 +18,9 @@
|
||||
:options="addressTypeOptions"
|
||||
:label="t('commercial.suppliers.form.address.addressType')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.addressType"
|
||||
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
|
||||
/>
|
||||
@@ -31,24 +32,28 @@
|
||||
:label="t('commercial.suppliers.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif). -->
|
||||
<MalioSelectCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.contactIris)"
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('commercial.suppliers.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client
|
||||
porte ici l'email de facturation, absent cote fournisseur). -->
|
||||
<div aria-hidden="true" />
|
||||
porte ici l'email de facturation, absent cote fournisseur). Inutile en
|
||||
consultation masquee (la grille se recompose sans les champs vides). -->
|
||||
<div v-if="!hideEmpty" aria-hidden="true" />
|
||||
|
||||
<!-- Categories de type FOURNISSEUR (>= 1 obligatoire, RG-2.10). -->
|
||||
<MalioSelectCheckbox
|
||||
@@ -57,7 +62,8 @@
|
||||
:label="t('commercial.suppliers.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
@@ -67,7 +73,8 @@
|
||||
:options="countryOptions"
|
||||
:label="t('commercial.suppliers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
@@ -76,7 +83,8 @@
|
||||
:label="t('commercial.suppliers.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
@@ -88,17 +96,20 @@
|
||||
:options="cityOptions"
|
||||
:label="t('commercial.suppliers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.city"
|
||||
:label="t('commercial.suppliers.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
@@ -107,14 +118,15 @@
|
||||
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly"
|
||||
v-if="!readonly && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('commercial.suppliers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('commercial.suppliers.form.address.streetNotFound')"
|
||||
@@ -126,40 +138,50 @@
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('commercial.suppliers.form.address.street')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1">
|
||||
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('commercial.suppliers.form.address.streetComplement')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bennes : stepper (specifique fournisseur, defaut 0). -->
|
||||
<!-- Bennes : stepper (specifique fournisseur, defaut 0). En consultation, 0
|
||||
reste affiche (valeur saisie) ; seul un champ vide serait masque. -->
|
||||
<MalioInputNumber
|
||||
v-if="!hideEmpty || isFilled(model.bennes)"
|
||||
:model-value="model.bennes"
|
||||
:label="t('commercial.suppliers.form.address.bennes')"
|
||||
:min="0"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.bennes"
|
||||
@update:model-value="(v: string) => update('bennes', v)"
|
||||
/>
|
||||
|
||||
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur). -->
|
||||
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur).
|
||||
Consultation : masquee si non cochee (ERP-193). -->
|
||||
<MalioCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.triageProvider)"
|
||||
id="address-triage-provider"
|
||||
:label="t('commercial.suppliers.form.address.triageProvider')"
|
||||
:model-value="model.triageProvider"
|
||||
group-class="self-center"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: boolean) => update('triageProvider', v)"
|
||||
/>
|
||||
</div>
|
||||
@@ -169,6 +191,8 @@
|
||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
|
||||
import type { SupplierAddressFormDraft, SupplierAddressType } from '~/modules/commercial/types/supplierForm'
|
||||
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque code postal FR : 5 chiffres.
|
||||
const POSTAL_CODE_MASK = '#####'
|
||||
@@ -187,6 +211,10 @@ const props = defineProps<{
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -238,11 +266,37 @@ const addressLoading = ref(false)
|
||||
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||
let lastAddressSuggestions: AddressSuggestion[] = []
|
||||
|
||||
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur
|
||||
// les champs texte editables (complement, ville en mode degrade, voie en repli). La
|
||||
// voie en autocomplete (BAN) et la ville en select ne sont pas masquees (le back
|
||||
// valide via Assert\Regex).
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof SupplierAddressFormDraft>(field: K, value: SupplierAddressFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
|
||||
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
|
||||
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
|
||||
* a chaque frappe).
|
||||
*/
|
||||
function onCityChange(value: string | number | null): void {
|
||||
const next = value === null ? null : String(value)
|
||||
if (next === (props.modelValue.city ?? null)) {
|
||||
return
|
||||
}
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
city: next,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
||||
function notifyUnavailable(): void {
|
||||
if (!unavailableNotified) {
|
||||
@@ -253,9 +307,27 @@ function notifyUnavailable(): void {
|
||||
|
||||
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
|
||||
async function onPostalCodeChange(value: string): Promise<void> {
|
||||
update('postalCode', value)
|
||||
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
|
||||
|
||||
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
|
||||
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
|
||||
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
|
||||
if (digits.length === 5 && digits !== previousDigits) {
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
postalCode: value,
|
||||
city: null,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
else {
|
||||
update('postalCode', value)
|
||||
}
|
||||
|
||||
if (digits.length < 5) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||
non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -12,44 +12,56 @@
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||
:model-value="model.lastName"
|
||||
:label="t('commercial.suppliers.form.contact.lastName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||
:model-value="model.firstName"
|
||||
:label="t('commercial.suppliers.form.contact.firstName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div class="col-span-2">
|
||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('commercial.suppliers.form.contact.jobTitle')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
v-if="!hideEmpty || isFilled(model.email)"
|
||||
:model-value="model.email"
|
||||
:label="t('commercial.suppliers.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('commercial.suppliers.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('commercial.suppliers.form.contact.addPhone')"
|
||||
@@ -57,11 +69,12 @@
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone"
|
||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('commercial.suppliers.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
@@ -70,6 +83,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SupplierContactFormDraft } from '~/modules/commercial/types/supplierForm'
|
||||
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
|
||||
const PHONE_MASK = '## ## ## ## ##'
|
||||
@@ -83,6 +98,10 @@ const props = defineProps<{
|
||||
removable?: boolean
|
||||
/** Bloc en lecture seule (onglet valide). */
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -97,6 +116,10 @@ const { t } = useI18n()
|
||||
// Alias local pour la lisibilite du template.
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
// Filtrage des caracteres parasites : porte par les masks maska sur les champs
|
||||
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
|
||||
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof SupplierContactFormDraft>(field: K, value: SupplierContactFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
|
||||
@@ -171,6 +171,182 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Stub MalioInputText emetteur : re-expose `label` et relaie `update:model-value`,
|
||||
* pour piloter le champ Code postal et observer le brouillon emis.
|
||||
*/
|
||||
const MalioInputTextEmitter = defineComponent({
|
||||
name: 'MalioInputTextEmitter',
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null], default: undefined },
|
||||
label: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props) {
|
||||
return () => h('div', { 'data-testid': 'addr-input', 'data-label': props.label })
|
||||
},
|
||||
})
|
||||
|
||||
describe('ClientAddressBlock — changement de code postal vide les champs dependants (ERP-193)', () => {
|
||||
beforeEach(() => {
|
||||
searchCityMock.mockReset()
|
||||
searchCityMock.mockResolvedValue([])
|
||||
})
|
||||
|
||||
function mountFilled() {
|
||||
return mount(ClientAddressBlock, {
|
||||
props: {
|
||||
modelValue: {
|
||||
...emptyAddress(),
|
||||
postalCode: '75001',
|
||||
city: 'Paris',
|
||||
street: '8 Boulevard du Port',
|
||||
streetComplement: 'Bat A',
|
||||
},
|
||||
title: 'Adresse',
|
||||
categoryOptions: [],
|
||||
siteOptions: [],
|
||||
contactOptions: [],
|
||||
countryOptions: [],
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioCheckbox: true,
|
||||
MalioSelect: true,
|
||||
MalioSelectCheckbox: true,
|
||||
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||
MalioInputText: MalioInputTextEmitter,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function postalCodeField(wrapper: ReturnType<typeof mountFilled>) {
|
||||
return wrapper.findAllComponents(MalioInputTextEmitter).find(
|
||||
c => c.props('label') === 'commercial.clients.form.address.postalCode',
|
||||
)
|
||||
}
|
||||
|
||||
it('vide ville, adresse et complement quand le CP complet change', async () => {
|
||||
const wrapper = mountFilled()
|
||||
|
||||
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '33000')
|
||||
await flushPromises()
|
||||
|
||||
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
||||
expect(last.postalCode).toBe('33000')
|
||||
expect(last.city).toBeNull()
|
||||
expect(last.street).toBeNull()
|
||||
expect(last.streetComplement).toBeNull()
|
||||
})
|
||||
|
||||
it('ne vide pas les champs si le CP reste incomplet (< 5 chiffres)', async () => {
|
||||
const wrapper = mountFilled()
|
||||
|
||||
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '7500')
|
||||
await flushPromises()
|
||||
|
||||
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
||||
expect(last.postalCode).toBe('7500')
|
||||
expect(last.city).toBe('Paris')
|
||||
expect(last.street).toBe('8 Boulevard du Port')
|
||||
expect(last.streetComplement).toBe('Bat A')
|
||||
})
|
||||
|
||||
it('ne vide pas les champs si le CP complet est identique', async () => {
|
||||
const wrapper = mountFilled()
|
||||
|
||||
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '75001')
|
||||
await flushPromises()
|
||||
|
||||
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
||||
expect(last.city).toBe('Paris')
|
||||
expect(last.street).toBe('8 Boulevard du Port')
|
||||
expect(last.streetComplement).toBe('Bat A')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Stub MalioSelect emetteur : re-expose `label` et relaie `update:model-value`,
|
||||
* pour piloter le select Ville et observer le brouillon emis.
|
||||
*/
|
||||
const MalioSelectEmitter = defineComponent({
|
||||
name: 'MalioSelectEmitter',
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null], default: undefined },
|
||||
label: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props) {
|
||||
return () => h('div', { 'data-testid': 'addr-select', 'data-label': props.label })
|
||||
},
|
||||
})
|
||||
|
||||
describe('ClientAddressBlock — changement de ville vide adresse + complement (ERP-193)', () => {
|
||||
beforeEach(() => {
|
||||
searchCityMock.mockReset()
|
||||
searchCityMock.mockResolvedValue([])
|
||||
})
|
||||
|
||||
function mountFilled() {
|
||||
return mount(ClientAddressBlock, {
|
||||
props: {
|
||||
modelValue: {
|
||||
...emptyAddress(),
|
||||
postalCode: '75001',
|
||||
city: 'Paris',
|
||||
street: '8 Boulevard du Port',
|
||||
streetComplement: 'Bat A',
|
||||
},
|
||||
title: 'Adresse',
|
||||
categoryOptions: [],
|
||||
siteOptions: [],
|
||||
contactOptions: [],
|
||||
countryOptions: [],
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioCheckbox: true,
|
||||
MalioSelectCheckbox: true,
|
||||
MalioInputText: true,
|
||||
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||
MalioSelect: MalioSelectEmitter,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function cityField(wrapper: ReturnType<typeof mountFilled>) {
|
||||
return wrapper.findAllComponents(MalioSelectEmitter).find(
|
||||
c => c.props('label') === 'commercial.clients.form.address.city',
|
||||
)
|
||||
}
|
||||
|
||||
it('vide adresse et complement quand la ville change', async () => {
|
||||
const wrapper = mountFilled()
|
||||
|
||||
cityField(wrapper)!.vm.$emit('update:modelValue', 'Lyon')
|
||||
await flushPromises()
|
||||
|
||||
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
||||
expect(last.city).toBe('Lyon')
|
||||
expect(last.street).toBeNull()
|
||||
expect(last.streetComplement).toBeNull()
|
||||
})
|
||||
|
||||
it('ne vide pas si la ville selectionnee est identique', async () => {
|
||||
const wrapper = mountFilled()
|
||||
|
||||
cityField(wrapper)!.vm.$emit('update:modelValue', 'Paris')
|
||||
await flushPromises()
|
||||
|
||||
// Aucun nouvel emit (valeur inchangee) → l'adresse reste intacte.
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => {
|
||||
beforeEach(() => {
|
||||
searchAddressMock.mockReset()
|
||||
|
||||
@@ -25,8 +25,8 @@ function makeHydra(total: number): HydraCollection<Client> {
|
||||
describe('useClientsRepository', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
|
||||
mockGet.mockResolvedValue(makeHydra(25))
|
||||
// 60 items → 3 pages a 25/page : permet de tester la navigation page 2.
|
||||
mockGet.mockResolvedValue(makeHydra(60))
|
||||
})
|
||||
|
||||
it('cible la ressource /clients en page 1 par defaut', async () => {
|
||||
@@ -35,7 +35,7 @@ describe('useClientsRepository', () => {
|
||||
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/clients',
|
||||
{ page: 1, itemsPerPage: 10 },
|
||||
{ page: 1, itemsPerPage: 25 },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
@@ -65,7 +65,7 @@ describe('useClientsRepository', () => {
|
||||
'siteId[]': ['1', '2'],
|
||||
archivedOnly: true,
|
||||
page: 1,
|
||||
itemsPerPage: 10,
|
||||
itemsPerPage: 25,
|
||||
},
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
@@ -78,7 +78,7 @@ describe('useClientsRepository', () => {
|
||||
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/clients',
|
||||
{ page: 1, itemsPerPage: 10 },
|
||||
{ page: 1, itemsPerPage: 25 },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -25,8 +25,8 @@ function makeHydra(total: number): HydraCollection<Supplier> {
|
||||
describe('useSuppliersRepository', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
|
||||
mockGet.mockResolvedValue(makeHydra(25))
|
||||
// 60 items → 3 pages a 25/page : permet de tester la navigation page 2.
|
||||
mockGet.mockResolvedValue(makeHydra(60))
|
||||
})
|
||||
|
||||
it('cible la ressource /suppliers en page 1 par defaut', async () => {
|
||||
@@ -35,7 +35,7 @@ describe('useSuppliersRepository', () => {
|
||||
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/suppliers',
|
||||
{ page: 1, itemsPerPage: 10 },
|
||||
{ page: 1, itemsPerPage: 25 },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
@@ -65,7 +65,7 @@ describe('useSuppliersRepository', () => {
|
||||
'siteId[]': ['86', '17'],
|
||||
archivedOnly: true,
|
||||
page: 1,
|
||||
itemsPerPage: 10,
|
||||
itemsPerPage: 25,
|
||||
},
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
@@ -78,7 +78,7 @@ describe('useSuppliersRepository', () => {
|
||||
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/suppliers',
|
||||
{ page: 1, itemsPerPage: 10 },
|
||||
{ page: 1, itemsPerPage: 25 },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -49,5 +49,6 @@ export interface Client {
|
||||
* gerer.
|
||||
*/
|
||||
export function useClientsRepository() {
|
||||
return usePaginatedList<Client>({ url: '/clients' })
|
||||
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
|
||||
return usePaginatedList<Client>({ url: '/clients', defaultItemsPerPage: 25 })
|
||||
}
|
||||
|
||||
@@ -51,5 +51,6 @@ export interface Supplier {
|
||||
* `usePaginatedList`. Aucun reset au logout a gerer.
|
||||
*/
|
||||
export function useSuppliersRepository() {
|
||||
return usePaginatedList<Supplier>({ url: '/suppliers' })
|
||||
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
|
||||
return usePaginatedList<Supplier>({ url: '/suppliers', defaultItemsPerPage: 25 })
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('commercial.clients.edit.back')"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.edit.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -25,9 +26,10 @@
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="main.companyName"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:label="t('commercial.clients.form.main.companyName')"
|
||||
:required="true"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="mainErrors.errors.companyName"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
@@ -35,7 +37,7 @@
|
||||
:options="mainCategoryOptions"
|
||||
:label="t('commercial.clients.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.categories"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
@@ -46,7 +48,7 @@
|
||||
:options="relationOptions"
|
||||
:label="t('commercial.clients.form.main.relation')"
|
||||
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
@update:model-value="onRelationChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
@@ -54,7 +56,7 @@
|
||||
:model-value="main.brokerIri"
|
||||
:options="brokerOptions"
|
||||
:label="t('commercial.clients.form.main.brokerName')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.broker"
|
||||
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
||||
@@ -64,7 +66,7 @@
|
||||
:model-value="main.distributorIri"
|
||||
:options="distributorOptions"
|
||||
:label="t('commercial.clients.form.main.distributorName')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.distributor"
|
||||
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
||||
@@ -74,7 +76,7 @@
|
||||
v-model="main.triageService"
|
||||
:label="t('commercial.clients.form.main.triageService')"
|
||||
group-class="self-center"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -101,20 +103,24 @@
|
||||
resize="none"
|
||||
group-class="row-span-2 pt-1 pb-1"
|
||||
text-input="h-full text-lg"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.description"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.competitors"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:label="t('commercial.clients.form.information.competitors')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.competitors"
|
||||
/>
|
||||
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
|
||||
le calendrier a aujourd'hui et invalide une saisie future. -->
|
||||
<MalioDate
|
||||
v-model="information.foundedAt"
|
||||
:label="t('commercial.clients.form.information.foundedAt')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:editable="true"
|
||||
:max="maxFoundedAt"
|
||||
:error="informationErrors.errors.foundedAt"
|
||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||
/>
|
||||
@@ -122,25 +128,30 @@
|
||||
v-model="information.employeesCount"
|
||||
:label="t('commercial.clients.form.information.employeesCount')"
|
||||
:mask="EMPLOYEES_MASK"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.employeesCount"
|
||||
/>
|
||||
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
|
||||
:key force le re-affichage quand on plafonne (modelValue inchange). -->
|
||||
<MalioInputAmount
|
||||
v-model="information.revenueAmount"
|
||||
:key="revenueAmountKey"
|
||||
:model-value="information.revenueAmount"
|
||||
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.revenueAmount"
|
||||
@update:model-value="onRevenueAmountInput"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.directorName"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:label="t('commercial.clients.form.information.directorName')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.directorName"
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-model="information.profitAmount"
|
||||
:label="t('commercial.clients.form.information.profitAmount')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.profitAmount"
|
||||
/>
|
||||
</div>
|
||||
@@ -167,7 +178,7 @@
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
@@ -204,7 +215,7 @@
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@@ -239,14 +250,15 @@
|
||||
v-model="accounting.siren"
|
||||
:label="t('commercial.clients.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.siren"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('commercial.clients.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.accountNumber"
|
||||
/>
|
||||
@@ -254,7 +266,7 @@
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="tvaModeOptions"
|
||||
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.tvaMode"
|
||||
@@ -262,8 +274,9 @@
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('commercial.clients.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.nTva"
|
||||
/>
|
||||
@@ -271,7 +284,7 @@
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="paymentDelayOptions"
|
||||
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentDelay"
|
||||
@@ -281,7 +294,7 @@
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="paymentTypeOptions"
|
||||
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentType"
|
||||
@@ -292,7 +305,7 @@
|
||||
:model-value="accounting.bankIri"
|
||||
:options="bankOptions"
|
||||
:label="t('commercial.clients.form.accounting.bank')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.bank"
|
||||
@@ -319,21 +332,23 @@
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.label"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('commercial.clients.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.bic"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('commercial.clients.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.iban"
|
||||
/>
|
||||
@@ -423,6 +438,9 @@ import {
|
||||
type InformationFormDraft,
|
||||
type MainFormDraft,
|
||||
} from '~/modules/commercial/utils/forms/clientEdit'
|
||||
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
|
||||
import { todayIso } from '~/shared/utils/date'
|
||||
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||
import {
|
||||
buildClientFormTabKeys,
|
||||
isAddressValid,
|
||||
@@ -491,6 +509,22 @@ const headerTitle = computed(() => client.value?.companyName ?? t('commercial.cl
|
||||
const main = reactive<MainFormDraft>(mapMainDraft({} as ClientDetail))
|
||||
const information = reactive<InformationFormDraft>(mapInformationDraft({} as ClientDetail))
|
||||
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as ClientDetail))
|
||||
|
||||
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
|
||||
const maxFoundedAt = todayIso()
|
||||
|
||||
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
|
||||
// champ controle quand le plafonnement laisse le modelValue inchange.
|
||||
const revenueAmountKey = ref(0)
|
||||
|
||||
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
|
||||
function onRevenueAmountInput(value: string | null): void {
|
||||
const clamped = clampRevenueAmount(value)
|
||||
information.revenueAmount = clamped ?? null
|
||||
if (clamped !== value) {
|
||||
revenueAmountKey.value += 1
|
||||
}
|
||||
}
|
||||
const contacts = ref<ContactFormDraft[]>([])
|
||||
const addresses = ref<AddressFormDraft[]>([])
|
||||
const ribs = ref<RibFormDraft[]>([])
|
||||
@@ -668,6 +702,11 @@ function showError(e: unknown, opts: { duplicateCompany?: boolean } = {}): void
|
||||
})
|
||||
}
|
||||
|
||||
/** Toast de succès après suppression serveur confirmée d'un bloc (contact / adresse / RIB). */
|
||||
function notifyRemovalSuccess(): void {
|
||||
toast.success({ title: t('success.title'), message: t('success.deleted') })
|
||||
}
|
||||
|
||||
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
|
||||
// Etat d'erreurs factorise avec l'ecran de creation (cf. useClientFormErrors) :
|
||||
// un `useFormErrors` par groupe scalaire + un tableau d'erreurs par ligne pour
|
||||
@@ -767,6 +806,7 @@ function askRemoveContact(index: number): void {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyContact,
|
||||
onError: showError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -844,6 +884,7 @@ function askRemoveAddress(index: number): void {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyAddress,
|
||||
onError: showError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -944,6 +985,7 @@ function askRemoveRib(index: number): void {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyRib,
|
||||
onError: showError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('commercial.clients.consultation.back')"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -23,7 +24,7 @@
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showArchive"
|
||||
variant="secondary"
|
||||
variant="danger"
|
||||
icon-name="mdi:archive-arrow-down-outline"
|
||||
icon-position="left"
|
||||
:label="t('commercial.clients.action.archive')"
|
||||
@@ -48,43 +49,51 @@
|
||||
<!-- ── Formulaire principal (lecture seule) ──────────────────────── -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="isFilled(client.companyName)"
|
||||
:model-value="client.companyName"
|
||||
:label="t('commercial.clients.form.main.companyName')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
v-if="isFilled(categoryIris)"
|
||||
:model-value="categoryIris"
|
||||
:options="mainCategoryOptions"
|
||||
:label="t('commercial.clients.form.main.categories')"
|
||||
:display-tag="true"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<!-- Relation toujours affichee (vide = « Aucun »), comme en edition. -->
|
||||
<!-- Relation : masquee en consultation si aucune (ERP-193) ; en edition
|
||||
elle reste toujours visible (vide = « Aucun »). -->
|
||||
<MalioSelect
|
||||
v-if="isFilled(relation.type)"
|
||||
:model-value="relation.type"
|
||||
:options="relationOptions"
|
||||
:label="t('commercial.clients.form.main.relation')"
|
||||
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant,
|
||||
aucune valeur sans relation — meme comportement qu'en edition). -->
|
||||
<MalioInputText
|
||||
v-if="relation.type"
|
||||
v-if="relation.type && isFilled(relation.name)"
|
||||
:model-value="relation.name"
|
||||
:label="relation.type === 'distributeur' ? t('commercial.clients.form.main.distributorName') : t('commercial.clients.form.main.brokerName')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<!-- Service de triage : case a cocher masquee si non cochee (ERP-193). -->
|
||||
<MalioCheckbox
|
||||
v-if="isFilled(client.triageService === true)"
|
||||
:model-value="client.triageService === true"
|
||||
:label="t('commercial.clients.form.main.triageService')"
|
||||
group-class="self-center"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||
<!-- ERP-193 : on n'affiche la barre que s'il reste au moins un onglet
|
||||
non vide (sinon seul le bloc principal est visible). -->
|
||||
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||
<!-- Onglet Information -->
|
||||
<template #information>
|
||||
<div class="mt-12 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)]">
|
||||
@@ -92,42 +101,49 @@
|
||||
sur les inputs (champ 40px centre dans un h-12 -> ~4px de
|
||||
coussin de chaque cote). -->
|
||||
<MalioInputTextArea
|
||||
v-if="isFilled(information.description)"
|
||||
:model-value="information.description"
|
||||
:label="t('commercial.clients.form.information.description')"
|
||||
resize="none"
|
||||
group-class="row-span-2 pt-1 pb-1"
|
||||
text-input="h-full text-lg"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(information.competitors)"
|
||||
:model-value="information.competitors"
|
||||
:label="t('commercial.clients.form.information.competitors')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioDate
|
||||
v-if="isFilled(information.foundedAt)"
|
||||
:model-value="information.foundedAt"
|
||||
:label="t('commercial.clients.form.information.foundedAt')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(information.employeesCount)"
|
||||
:model-value="information.employeesCount"
|
||||
:label="t('commercial.clients.form.information.employeesCount')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-if="isFilled(information.revenueAmount)"
|
||||
:model-value="information.revenueAmount"
|
||||
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(information.directorName)"
|
||||
:model-value="information.directorName"
|
||||
:label="t('commercial.clients.form.information.directorName')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-if="isFilled(information.profitAmount)"
|
||||
:model-value="information.profitAmount"
|
||||
:label="t('commercial.clients.form.information.profitAmount')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -140,7 +156,8 @@
|
||||
:key="contact.id ?? index"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
readonly
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -157,7 +174,8 @@
|
||||
:site-options="allSiteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
readonly
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -168,41 +186,47 @@
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="isFilled(accounting.siren)"
|
||||
:model-value="accounting.siren"
|
||||
:label="t('commercial.clients.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(accounting.accountNumber)"
|
||||
:model-value="accounting.accountNumber"
|
||||
:label="t('commercial.clients.form.accounting.accountNumber')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="isFilled(accounting.tvaModeIri)"
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="tvaModeOptions"
|
||||
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(accounting.nTva)"
|
||||
:model-value="accounting.nTva"
|
||||
:label="t('commercial.clients.form.accounting.nTva')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="isFilled(accounting.paymentDelayIri)"
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="paymentDelayOptions"
|
||||
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="isFilled(accounting.paymentTypeIri)"
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="paymentTypeOptions"
|
||||
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="accounting.bankIri"
|
||||
@@ -210,7 +234,7 @@
|
||||
:options="bankOptions"
|
||||
:label="t('commercial.clients.form.accounting.bank')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,30 +247,30 @@
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="isFilled(rib.label)"
|
||||
:model-value="rib.label"
|
||||
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(rib.bic)"
|
||||
:model-value="rib.bic"
|
||||
:label="t('commercial.clients.form.accounting.ribBic')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(rib.iban)"
|
||||
:model-value="rib.iban"
|
||||
:label="t('commercial.clients.form.accounting.ribIban')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
|
||||
<template #transport><ComingSoonPlaceholder /></template>
|
||||
<template #statistics><ComingSoonPlaceholder /></template>
|
||||
<template #reports><ComingSoonPlaceholder /></template>
|
||||
<template #exchanges><ComingSoonPlaceholder /></template>
|
||||
<!-- ERP-193 : les onglets « a venir » (Transport / Statistiques /
|
||||
Rapports / Echanges) ne sont plus rendus en consultation
|
||||
(masquage des onglets vides) — slots supprimes. -->
|
||||
</MalioTabList>
|
||||
</template>
|
||||
|
||||
@@ -278,13 +302,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useClient } from '~/modules/commercial/composables/useClient'
|
||||
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import { isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
import {
|
||||
canEditClient,
|
||||
categoryOptionsOf,
|
||||
clientConsultationVisibleTabs,
|
||||
contactOptionsOf,
|
||||
mapAccountingDraft,
|
||||
mapAddressView,
|
||||
@@ -299,6 +324,7 @@ import {
|
||||
type SelectOption,
|
||||
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
@@ -412,9 +438,11 @@ const paymentTypeOptions = computed(() => referentialOptionOf(client.value?.paym
|
||||
const bankOptions = computed(() => referentialOptionOf(client.value?.bank))
|
||||
|
||||
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
|
||||
// 4 onglets actifs (Information, Contact, Adresse, + Comptabilite si droit) et
|
||||
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
|
||||
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
|
||||
// ERP-193 (retour metier) : on masque les coquilles non implementees ET tout
|
||||
// onglet de donnees vide. La liste depend donc du payload charge.
|
||||
const visibleTabKeys = computed(() => clientConsultationVisibleTabs(client.value, {
|
||||
canAccountingView: canAccountingView.value,
|
||||
}))
|
||||
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
information: 'mdi:account-outline',
|
||||
@@ -427,14 +455,26 @@ const TAB_ICONS: Record<string, string> = {
|
||||
exchanges: 'mdi:account-group-outline',
|
||||
}
|
||||
|
||||
const tabs = computed(() => tabKeys.value.map(key => ({
|
||||
const tabs = computed(() => visibleTabKeys.value.map(key => ({
|
||||
key,
|
||||
label: t(`commercial.clients.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
})))
|
||||
|
||||
// Onglet initial : repris de l'edition au retour (history.state), sinon Information.
|
||||
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
|
||||
// Onglet initial : vide tant que le client n'est pas charge. Des que la liste
|
||||
// des onglets visibles est connue, on cale sur l'onglet repris de l'edition
|
||||
// (history.state) s'il est encore visible, sinon le premier onglet visible.
|
||||
// Un watcher recale aussi si l'onglet courant disparait (ex: changement de droit).
|
||||
const activeTab = ref('')
|
||||
watch(visibleTabKeys, (keys) => {
|
||||
if (keys.length === 0) {
|
||||
activeTab.value = ''
|
||||
return
|
||||
}
|
||||
if (!keys.includes(activeTab.value)) {
|
||||
activeTab.value = readHistoryTab(keys) ?? keys[0]
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||
function goBack(): void {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<!-- Categories : codes stables separes par une virgule (ERP-78). -->
|
||||
<!-- Categories : libelles (name) separes par une virgule (ERP-193). -->
|
||||
<template #cell-categories="{ item }">
|
||||
{{ formatCategories(item) }}
|
||||
</template>
|
||||
@@ -209,10 +209,10 @@ const columns = [
|
||||
{ key: 'lastActivity', label: t('commercial.clients.column.lastActivity') },
|
||||
]
|
||||
|
||||
/** Codes des categories du client, separes par une virgule (ERP-78). */
|
||||
/** Libelles (name) des categories du client, separes par une virgule (ERP-193). */
|
||||
function formatCategories(item: Record<string, unknown>): string {
|
||||
const categories = (item.categories as Client['categories']) ?? []
|
||||
return categories.map(c => c.code).join(', ')
|
||||
return categories.map(c => c.name).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('commercial.clients.form.back')"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -19,9 +20,10 @@
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="main.companyName"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:label="t('commercial.clients.form.main.companyName')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:error="mainErrors.errors.companyName"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
@@ -29,7 +31,7 @@
|
||||
:options="referentials.categories.value"
|
||||
:label="t('commercial.clients.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.categories"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
@@ -40,7 +42,7 @@
|
||||
:options="relationOptions"
|
||||
:label="t('commercial.clients.form.main.relation')"
|
||||
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
@update:model-value="onRelationChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
@@ -48,7 +50,7 @@
|
||||
:model-value="main.brokerIri"
|
||||
:options="referentials.brokers.value"
|
||||
:label="t('commercial.clients.form.main.brokerName')"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.broker"
|
||||
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
||||
@@ -58,7 +60,7 @@
|
||||
:model-value="main.distributorIri"
|
||||
:options="referentials.distributors.value"
|
||||
:label="t('commercial.clients.form.main.distributorName')"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.distributor"
|
||||
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
||||
@@ -68,7 +70,7 @@
|
||||
v-model="main.triageService"
|
||||
:label="t('commercial.clients.form.main.triageService')"
|
||||
group-class="self-center"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -96,20 +98,24 @@
|
||||
resize="none"
|
||||
group-class="row-span-2 pt-1 pb-1"
|
||||
text-input="h-full text-lg"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.description"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.competitors"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:label="t('commercial.clients.form.information.competitors')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.competitors"
|
||||
/>
|
||||
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
|
||||
le calendrier a aujourd'hui et invalide une saisie future. -->
|
||||
<MalioDate
|
||||
v-model="information.foundedAt"
|
||||
:label="t('commercial.clients.form.information.foundedAt')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:editable="true"
|
||||
:max="maxFoundedAt"
|
||||
:error="informationErrors.errors.foundedAt"
|
||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||
/>
|
||||
@@ -117,37 +123,42 @@
|
||||
v-model="information.employeesCount"
|
||||
:label="t('commercial.clients.form.information.employeesCount')"
|
||||
:mask="EMPLOYEES_MASK"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.employeesCount"
|
||||
/>
|
||||
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
|
||||
:key force le re-affichage quand on plafonne (modelValue inchange). -->
|
||||
<MalioInputAmount
|
||||
v-model="information.revenueAmount"
|
||||
:key="revenueAmountKey"
|
||||
:model-value="information.revenueAmount"
|
||||
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.revenueAmount"
|
||||
@update:model-value="onRevenueAmountInput"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.directorName"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:label="t('commercial.clients.form.information.directorName')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.directorName"
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-model="information.profitAmount"
|
||||
:label="t('commercial.clients.form.information.profitAmount')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.profitAmount"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
|
||||
<!-- Desactive tant que le client n'est pas cree (evite un PATCH
|
||||
avant le POST si clic trop tot, Information etant l'onglet
|
||||
actif par defaut). Onglet facultatif : un enregistrement a
|
||||
vide reste possible, c'est le back qui valide. -->
|
||||
<!-- Masque tant que le client n'est pas cree : Information etant
|
||||
l'onglet actif par defaut, son Valider ne doit pas apparaitre a
|
||||
cote de celui du formulaire principal (ERP-193). Onglet facultatif :
|
||||
un enregistrement a vide reste possible, c'est le back qui valide. -->
|
||||
<div v-if="!isValidated('information') && clientId !== null" class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('commercial.clients.form.submit')"
|
||||
:disabled="tabSubmitting || clientId === null"
|
||||
:disabled="tabSubmitting"
|
||||
@click="submitInformation"
|
||||
/>
|
||||
</div>
|
||||
@@ -166,7 +177,7 @@
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="isValidated('contact')"
|
||||
:disabled="isValidated('contact')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
@@ -203,7 +214,7 @@
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="isValidated('address')"
|
||||
:disabled="isValidated('address')"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@@ -237,14 +248,15 @@
|
||||
v-model="accounting.siren"
|
||||
:label="t('commercial.clients.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.siren"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('commercial.clients.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.accountNumber"
|
||||
/>
|
||||
@@ -252,7 +264,7 @@
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="referentials.tvaModes.value"
|
||||
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.tvaMode"
|
||||
@@ -260,8 +272,9 @@
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('commercial.clients.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.nTva"
|
||||
/>
|
||||
@@ -269,7 +282,7 @@
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="referentials.paymentDelays.value"
|
||||
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentDelay"
|
||||
@@ -279,7 +292,7 @@
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="referentials.paymentTypes.value"
|
||||
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentType"
|
||||
@@ -290,7 +303,7 @@
|
||||
:model-value="accounting.bankIri"
|
||||
:options="referentials.banks.value"
|
||||
:label="t('commercial.clients.form.accounting.bank')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.bank"
|
||||
@@ -318,21 +331,23 @@
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.label"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('commercial.clients.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.bic"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('commercial.clients.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.iban"
|
||||
/>
|
||||
@@ -407,6 +422,9 @@ import {
|
||||
lastFillableTabKey,
|
||||
showsRelationAndTriageFields,
|
||||
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
|
||||
import { todayIso } from '~/shared/utils/date'
|
||||
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||
import {
|
||||
buildAddressPayload,
|
||||
buildMainPayload,
|
||||
@@ -665,6 +683,22 @@ const information = reactive({
|
||||
directorName: null as string | null,
|
||||
})
|
||||
|
||||
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
|
||||
const maxFoundedAt = todayIso()
|
||||
|
||||
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
|
||||
// champ controle quand le plafonnement laisse le modelValue inchange.
|
||||
const revenueAmountKey = ref(0)
|
||||
|
||||
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
|
||||
function onRevenueAmountInput(value: string | null): void {
|
||||
const clamped = clampRevenueAmount(value)
|
||||
information.revenueAmount = clamped ?? null
|
||||
if (clamped !== value) {
|
||||
revenueAmountKey.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
|
||||
async function submitInformation(): Promise<void> {
|
||||
if (clientId.value === null || tabSubmitting.value) return
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('commercial.suppliers.edit.back')"
|
||||
v-bind="{ ariaLabel: t('commercial.suppliers.edit.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -26,15 +27,16 @@
|
||||
v-model="main.companyName"
|
||||
:label="t('commercial.suppliers.form.main.companyName')"
|
||||
:required="true"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="mainErrors.errors.companyName"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="main.categoryIris"
|
||||
:options="mainCategoryOptions"
|
||||
:label="t('commercial.suppliers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.categories"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
@@ -62,20 +64,24 @@
|
||||
resize="none"
|
||||
group-class="row-span-2 pt-1 pb-1"
|
||||
text-input="h-full text-lg"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.description"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.competitors"
|
||||
:label="t('commercial.suppliers.form.information.competitors')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.competitors"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
/>
|
||||
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
|
||||
le calendrier a aujourd'hui et invalide une saisie future. -->
|
||||
<MalioDate
|
||||
v-model="information.foundedAt"
|
||||
:label="t('commercial.suppliers.form.information.foundedAt')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:editable="true"
|
||||
:max="maxFoundedAt"
|
||||
:error="informationErrors.errors.foundedAt"
|
||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||
/>
|
||||
@@ -83,25 +89,30 @@
|
||||
v-model="information.employeesCount"
|
||||
:label="t('commercial.suppliers.form.information.employeesCount')"
|
||||
:mask="EMPLOYEES_MASK"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.employeesCount"
|
||||
/>
|
||||
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
|
||||
:key force le re-affichage quand on plafonne (modelValue inchange). -->
|
||||
<MalioInputAmount
|
||||
v-model="information.revenueAmount"
|
||||
:key="revenueAmountKey"
|
||||
:model-value="information.revenueAmount"
|
||||
:label="t('commercial.suppliers.form.information.revenueAmount')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.revenueAmount"
|
||||
@update:model-value="onRevenueAmountInput"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.directorName"
|
||||
:label="t('commercial.suppliers.form.information.directorName')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.directorName"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-model="information.profitAmount"
|
||||
:label="t('commercial.suppliers.form.information.profitAmount')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.profitAmount"
|
||||
/>
|
||||
<!-- Volume previsionnel : specifique fournisseur (entier). -->
|
||||
@@ -109,7 +120,7 @@
|
||||
v-model="information.volumeForecast"
|
||||
:label="t('commercial.suppliers.form.information.volumeForecast')"
|
||||
:mask="VOLUME_FORECAST_MASK"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.volumeForecast"
|
||||
/>
|
||||
</div>
|
||||
@@ -136,7 +147,7 @@
|
||||
:model-value="contact"
|
||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
@@ -173,7 +184,7 @@
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@@ -208,22 +219,23 @@
|
||||
v-model="accounting.siren"
|
||||
:label="t('commercial.suppliers.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.siren"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:label="t('commercial.suppliers.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.accountNumber"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="tvaModeOptions"
|
||||
:label="t('commercial.suppliers.form.accounting.tvaMode')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.tvaMode"
|
||||
@@ -232,15 +244,16 @@
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:label="t('commercial.suppliers.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.nTva"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="paymentDelayOptions"
|
||||
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentDelay"
|
||||
@@ -250,7 +263,7 @@
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="paymentTypeOptions"
|
||||
:label="t('commercial.suppliers.form.accounting.paymentType')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentType"
|
||||
@@ -261,7 +274,7 @@
|
||||
:model-value="accounting.bankIri"
|
||||
:options="bankOptions"
|
||||
:label="t('commercial.suppliers.form.accounting.bank')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.bank"
|
||||
@@ -288,23 +301,25 @@
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('commercial.suppliers.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.label"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:label="t('commercial.suppliers.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.bic"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:label="t('commercial.suppliers.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.iban"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -392,6 +407,8 @@ import {
|
||||
type MainFormDraft,
|
||||
type SupplierEditAbilities,
|
||||
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
|
||||
import { todayIso } from '~/shared/utils/date'
|
||||
import {
|
||||
buildSupplierFormTabKeys,
|
||||
isAddressValid,
|
||||
@@ -412,6 +429,7 @@ import {
|
||||
} from '~/modules/commercial/types/supplierForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
|
||||
// Masques de saisie (la normalisation finale reste serveur).
|
||||
@@ -457,6 +475,22 @@ const headerTitle = computed(() => supplier.value?.companyName ?? t('commercial.
|
||||
const main = reactive<MainFormDraft>(mapMainDraft({} as SupplierDetail))
|
||||
const information = reactive<InformationFormDraft>(mapInformationDraft({} as SupplierDetail))
|
||||
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as SupplierDetail))
|
||||
|
||||
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
|
||||
const maxFoundedAt = todayIso()
|
||||
|
||||
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
|
||||
// champ controle quand le plafonnement laisse le modelValue inchange.
|
||||
const revenueAmountKey = ref(0)
|
||||
|
||||
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
|
||||
function onRevenueAmountInput(value: string | null): void {
|
||||
const clamped = clampRevenueAmount(value)
|
||||
information.revenueAmount = clamped ?? null
|
||||
if (clamped !== value) {
|
||||
revenueAmountKey.value += 1
|
||||
}
|
||||
}
|
||||
const contacts = ref<SupplierContactFormDraft[]>([])
|
||||
const addresses = ref<SupplierAddressFormDraft[]>([])
|
||||
const ribs = ref<SupplierRibFormDraft[]>([])
|
||||
@@ -583,6 +617,11 @@ function showError(e: unknown): void {
|
||||
toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(e) })
|
||||
}
|
||||
|
||||
/** Toast de succès après suppression serveur confirmée d'un bloc (contact / adresse / RIB). */
|
||||
function notifyRemovalSuccess(): void {
|
||||
toast.success({ title: t('success.title'), message: t('success.deleted') })
|
||||
}
|
||||
|
||||
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
|
||||
const {
|
||||
mainErrors,
|
||||
@@ -666,6 +705,7 @@ function askRemoveContact(index: number): void {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyContact,
|
||||
onError: showError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -734,6 +774,7 @@ function askRemoveAddress(index: number): void {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyAddress,
|
||||
onError: showError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -833,6 +874,7 @@ function askRemoveRib(index: number): void {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyRib,
|
||||
onError: showError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('commercial.suppliers.consultation.back')"
|
||||
v-bind="{ ariaLabel: t('commercial.suppliers.consultation.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -23,7 +24,7 @@
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showArchive"
|
||||
variant="secondary"
|
||||
variant="danger"
|
||||
icon-name="mdi:archive-arrow-down-outline"
|
||||
icon-position="left"
|
||||
:label="t('commercial.suppliers.action.archive')"
|
||||
@@ -48,69 +49,82 @@
|
||||
<!-- ── Formulaire principal (lecture seule) ──────────────────────── -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="isFilled(supplier.companyName)"
|
||||
:model-value="supplier.companyName"
|
||||
:label="t('commercial.suppliers.form.main.companyName')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
v-if="isFilled(categoryIris)"
|
||||
:model-value="categoryIris"
|
||||
:options="mainCategoryOptions"
|
||||
:label="t('commercial.suppliers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||
<!-- Masque la barre d'onglets (et sa bordure) quand aucun onglet n'est
|
||||
visible : seul le formulaire principal est rempli (aligné sur le
|
||||
client). -->
|
||||
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||
<!-- Onglet Information -->
|
||||
<template #information>
|
||||
<div class="mt-12 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)]">
|
||||
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
|
||||
sur les inputs (champ 40px centre dans un h-12). -->
|
||||
<MalioInputTextArea
|
||||
v-if="isFilled(information.description)"
|
||||
:model-value="information.description"
|
||||
:label="t('commercial.suppliers.form.information.description')"
|
||||
resize="none"
|
||||
group-class="row-span-2 pt-1 pb-1"
|
||||
text-input="h-full text-lg"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(information.competitors)"
|
||||
:model-value="information.competitors"
|
||||
:label="t('commercial.suppliers.form.information.competitors')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioDate
|
||||
v-if="isFilled(information.foundedAt)"
|
||||
:model-value="information.foundedAt"
|
||||
:label="t('commercial.suppliers.form.information.foundedAt')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(information.employeesCount)"
|
||||
:model-value="information.employeesCount"
|
||||
:label="t('commercial.suppliers.form.information.employeesCount')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-if="isFilled(information.revenueAmount)"
|
||||
:model-value="information.revenueAmount"
|
||||
:label="t('commercial.suppliers.form.information.revenueAmount')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(information.directorName)"
|
||||
:model-value="information.directorName"
|
||||
:label="t('commercial.suppliers.form.information.directorName')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-if="isFilled(information.profitAmount)"
|
||||
:model-value="information.profitAmount"
|
||||
:label="t('commercial.suppliers.form.information.profitAmount')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<!-- Volume previsionnel : specifique fournisseur (entier). -->
|
||||
<MalioInputText
|
||||
v-if="isFilled(information.volumeForecast)"
|
||||
:model-value="information.volumeForecast"
|
||||
:label="t('commercial.suppliers.form.information.volumeForecast')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -123,7 +137,8 @@
|
||||
:key="contact.id ?? index"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||
readonly
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -140,7 +155,8 @@
|
||||
:site-options="allSiteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
readonly
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -151,41 +167,47 @@
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="isFilled(accounting.siren)"
|
||||
:model-value="accounting.siren"
|
||||
:label="t('commercial.suppliers.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(accounting.accountNumber)"
|
||||
:model-value="accounting.accountNumber"
|
||||
:label="t('commercial.suppliers.form.accounting.accountNumber')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="isFilled(accounting.tvaModeIri)"
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="tvaModeOptions"
|
||||
:label="t('commercial.suppliers.form.accounting.tvaMode')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(accounting.nTva)"
|
||||
:model-value="accounting.nTva"
|
||||
:label="t('commercial.suppliers.form.accounting.nTva')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="isFilled(accounting.paymentDelayIri)"
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="paymentDelayOptions"
|
||||
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="isFilled(accounting.paymentTypeIri)"
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="paymentTypeOptions"
|
||||
:label="t('commercial.suppliers.form.accounting.paymentType')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="accounting.bankIri"
|
||||
@@ -193,7 +215,7 @@
|
||||
:options="bankOptions"
|
||||
:label="t('commercial.suppliers.form.accounting.bank')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,30 +228,30 @@
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="isFilled(rib.label)"
|
||||
:model-value="rib.label"
|
||||
:label="t('commercial.suppliers.form.accounting.ribLabel')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(rib.bic)"
|
||||
:model-value="rib.bic"
|
||||
:label="t('commercial.suppliers.form.accounting.ribBic')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(rib.iban)"
|
||||
:model-value="rib.iban"
|
||||
:label="t('commercial.suppliers.form.accounting.ribIban')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
|
||||
<template #transport><ComingSoonPlaceholder /></template>
|
||||
<template #statistics><ComingSoonPlaceholder /></template>
|
||||
<template #reports><ComingSoonPlaceholder /></template>
|
||||
<template #exchanges><ComingSoonPlaceholder /></template>
|
||||
<!-- ERP-193 : les onglets « a venir » (Transport / Statistiques /
|
||||
Rapports / Echanges) ne sont plus rendus en consultation
|
||||
(masquage des onglets vides) — slots supprimes. -->
|
||||
</MalioTabList>
|
||||
</template>
|
||||
|
||||
@@ -261,9 +283,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
|
||||
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||
import { isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
import {
|
||||
canEditSupplier,
|
||||
@@ -278,10 +300,12 @@ import {
|
||||
referentialOptionOf,
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
supplierConsultationVisibleTabs,
|
||||
type SelectOption,
|
||||
type SupplierDetail,
|
||||
} from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||
import { emptyContact } from '~/modules/commercial/types/supplierForm'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
@@ -387,9 +411,11 @@ const paymentTypeOptions = computed(() => referentialOptionOf(supplier.value?.pa
|
||||
const bankOptions = computed(() => referentialOptionOf(supplier.value?.bank))
|
||||
|
||||
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
|
||||
// 3 onglets actifs (Information, Contacts, Adresses, + Comptabilite si droit) et
|
||||
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
|
||||
const tabKeys = computed(() => buildSupplierFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
|
||||
// ERP-193 (retour metier) : on masque les coquilles non implementees ET tout
|
||||
// onglet de donnees vide. La liste depend donc du payload charge.
|
||||
const visibleTabKeys = computed(() => supplierConsultationVisibleTabs(supplier.value, {
|
||||
canAccountingView: canAccountingView.value,
|
||||
}))
|
||||
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
information: 'mdi:account-outline',
|
||||
@@ -402,14 +428,25 @@ const TAB_ICONS: Record<string, string> = {
|
||||
exchanges: 'mdi:account-group-outline',
|
||||
}
|
||||
|
||||
const tabs = computed(() => tabKeys.value.map(key => ({
|
||||
const tabs = computed(() => visibleTabKeys.value.map(key => ({
|
||||
key,
|
||||
label: t(`commercial.suppliers.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
})))
|
||||
|
||||
// Onglet initial : repris de l'edition au retour (history.state), sinon Information.
|
||||
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
|
||||
// Onglet initial : vide tant que le fournisseur n'est pas charge. Des que la
|
||||
// liste des onglets visibles est connue, on cale sur l'onglet repris de
|
||||
// l'edition (history.state) s'il est encore visible, sinon le premier visible.
|
||||
const activeTab = ref('')
|
||||
watch(visibleTabKeys, (keys) => {
|
||||
if (keys.length === 0) {
|
||||
activeTab.value = ''
|
||||
return
|
||||
}
|
||||
if (!keys.includes(activeTab.value)) {
|
||||
activeTab.value = readHistoryTab(keys) ?? keys[0]
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||
function goBack(): void {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<!-- Categories : libelles (name) separes par une virgule (spec M2). -->
|
||||
<!-- Categories : libelles (name) separes par une virgule, aligne sur le client (ERP-193). -->
|
||||
<template #cell-categories="{ item }">
|
||||
{{ formatCategories(item) }}
|
||||
</template>
|
||||
@@ -209,7 +209,7 @@ const columns = [
|
||||
{ key: 'lastActivity', label: t('commercial.suppliers.column.lastActivity') },
|
||||
]
|
||||
|
||||
/** Libelles des categories du fournisseur, separes par une virgule (spec M2 : name). */
|
||||
/** Libelles (name) des categories du fournisseur, separes par une virgule (aligne sur le client, ERP-193). */
|
||||
function formatCategories(item: Record<string, unknown>): string {
|
||||
const categories = (item.categories as Supplier['categories']) ?? []
|
||||
return categories.map(c => c.name).join(', ')
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('commercial.suppliers.form.back')"
|
||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -21,15 +22,16 @@
|
||||
v-model="main.companyName"
|
||||
:label="t('commercial.suppliers.form.main.companyName')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:error="mainErrors.errors.companyName"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="main.categoryIris"
|
||||
:options="referentials.categories.value"
|
||||
:label="t('commercial.suppliers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.categories"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
@@ -56,20 +58,24 @@
|
||||
resize="none"
|
||||
group-class="row-span-2 pt-1 pb-1"
|
||||
text-input="h-full text-lg"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.description"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.competitors"
|
||||
:label="t('commercial.suppliers.form.information.competitors')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.competitors"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
/>
|
||||
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
|
||||
le calendrier a aujourd'hui et invalide une saisie future. -->
|
||||
<MalioDate
|
||||
v-model="information.foundedAt"
|
||||
:label="t('commercial.suppliers.form.information.foundedAt')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:editable="true"
|
||||
:max="maxFoundedAt"
|
||||
:error="informationErrors.errors.foundedAt"
|
||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||
/>
|
||||
@@ -77,25 +83,30 @@
|
||||
v-model="information.employeesCount"
|
||||
:label="t('commercial.suppliers.form.information.employeesCount')"
|
||||
:mask="EMPLOYEES_MASK"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.employeesCount"
|
||||
/>
|
||||
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
|
||||
:key force le re-affichage quand on plafonne (modelValue inchange). -->
|
||||
<MalioInputAmount
|
||||
v-model="information.revenueAmount"
|
||||
:key="revenueAmountKey"
|
||||
:model-value="information.revenueAmount"
|
||||
:label="t('commercial.suppliers.form.information.revenueAmount')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.revenueAmount"
|
||||
@update:model-value="onRevenueAmountInput"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.directorName"
|
||||
:label="t('commercial.suppliers.form.information.directorName')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.directorName"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-model="information.profitAmount"
|
||||
:label="t('commercial.suppliers.form.information.profitAmount')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.profitAmount"
|
||||
/>
|
||||
<!-- Volume previsionnel : specifique fournisseur. Champ texte
|
||||
@@ -104,15 +115,18 @@
|
||||
v-model="information.volumeForecast"
|
||||
:label="t('commercial.suppliers.form.information.volumeForecast')"
|
||||
:mask="VOLUME_FORECAST_MASK"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.volumeForecast"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
|
||||
<!-- Masque tant que le fournisseur n'est pas cree : Information etant
|
||||
l'onglet actif par defaut, son Valider ne doit pas apparaitre a cote
|
||||
de celui du formulaire principal (ERP-193). -->
|
||||
<div v-if="!isValidated('information') && supplierId !== null" class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('commercial.suppliers.form.submit')"
|
||||
:disabled="tabSubmitting || supplierId === null"
|
||||
:disabled="tabSubmitting"
|
||||
@click="submitInformation"
|
||||
/>
|
||||
</div>
|
||||
@@ -131,7 +145,7 @@
|
||||
:model-value="contact"
|
||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="isValidated('contacts')"
|
||||
:disabled="isValidated('contacts')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
@@ -168,7 +182,7 @@
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="isValidated('addresses')"
|
||||
:disabled="isValidated('addresses')"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@@ -202,22 +216,23 @@
|
||||
v-model="accounting.siren"
|
||||
:label="t('commercial.suppliers.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.siren"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:label="t('commercial.suppliers.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.accountNumber"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="referentials.tvaModes.value"
|
||||
:label="t('commercial.suppliers.form.accounting.tvaMode')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.tvaMode"
|
||||
@@ -226,15 +241,16 @@
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:label="t('commercial.suppliers.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.nTva"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="referentials.paymentDelays.value"
|
||||
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentDelay"
|
||||
@@ -244,7 +260,7 @@
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="referentials.paymentTypes.value"
|
||||
:label="t('commercial.suppliers.form.accounting.paymentType')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentType"
|
||||
@@ -255,7 +271,7 @@
|
||||
:model-value="accounting.bankIri"
|
||||
:options="referentials.banks.value"
|
||||
:label="t('commercial.suppliers.form.accounting.bank')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.bank"
|
||||
@@ -282,23 +298,25 @@
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('commercial.suppliers.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.label"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:label="t('commercial.suppliers.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.bic"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:label="t('commercial.suppliers.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.iban"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -375,6 +393,8 @@ import {
|
||||
buildMainPayload,
|
||||
buildRibPayload,
|
||||
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
|
||||
import { todayIso } from '~/shared/utils/date'
|
||||
import {
|
||||
emptyAddress,
|
||||
emptyContact,
|
||||
@@ -385,6 +405,7 @@ import {
|
||||
} from '~/modules/commercial/types/supplierForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||
|
||||
// Masques de saisie (la normalisation finale reste serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
@@ -564,6 +585,22 @@ const information = reactive({
|
||||
volumeForecast: null as string | null,
|
||||
})
|
||||
|
||||
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
|
||||
const maxFoundedAt = todayIso()
|
||||
|
||||
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
|
||||
// champ controle quand le plafonnement laisse le modelValue inchange.
|
||||
const revenueAmountKey = ref(0)
|
||||
|
||||
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
|
||||
function onRevenueAmountInput(value: string | null): void {
|
||||
const clamped = clampRevenueAmount(value)
|
||||
information.revenueAmount = clamped ?? null
|
||||
if (clamped !== value) {
|
||||
revenueAmountKey.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
/** PATCH /suppliers/{id} — mode strict : uniquement les champs du groupe information. */
|
||||
async function submitInformation(): Promise<void> {
|
||||
if (supplierId.value === null || tabSubmitting.value) return
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { clampRevenueAmount, REVENUE_AMOUNT_MAX } from '../amountInput'
|
||||
|
||||
describe('clampRevenueAmount', () => {
|
||||
it('laisse les valeurs vides / nulles telles quelles', () => {
|
||||
expect(clampRevenueAmount(null)).toBeNull()
|
||||
expect(clampRevenueAmount(undefined)).toBeUndefined()
|
||||
expect(clampRevenueAmount('')).toBe('')
|
||||
})
|
||||
|
||||
it('laisse une valeur sous le plafond inchangee', () => {
|
||||
expect(clampRevenueAmount('1000.50')).toBe('1000.50')
|
||||
expect(clampRevenueAmount('999999999999.99')).toBe('999999999999.99')
|
||||
})
|
||||
|
||||
it('plafonne une valeur au-dessus du maximum', () => {
|
||||
expect(clampRevenueAmount('1000000000000')).toBe('999999999999.99')
|
||||
expect(clampRevenueAmount('999999999999999.99')).toBe('999999999999.99')
|
||||
})
|
||||
|
||||
it('tolere une saisie a virgule / avec espaces (securite)', () => {
|
||||
expect(clampRevenueAmount('1 000 000 000 000,00')).toBe('999999999999.99')
|
||||
expect(clampRevenueAmount('12,5')).toBe('12,5')
|
||||
})
|
||||
|
||||
it('ne touche pas une saisie non numerique', () => {
|
||||
expect(clampRevenueAmount('abc')).toBe('abc')
|
||||
})
|
||||
|
||||
it('expose le plafond metier', () => {
|
||||
expect(REVENUE_AMOUNT_MAX).toBe(999_999_999_999.99)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
canEditClient,
|
||||
categoryOptionsOf,
|
||||
clientConsultationVisibleTabs,
|
||||
contactOptionsOf,
|
||||
hasAccountingData,
|
||||
hasInformationData,
|
||||
iriOf,
|
||||
mapAccountingDraft,
|
||||
mapAddressToDraft,
|
||||
@@ -248,3 +251,73 @@ describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', (
|
||||
expect(paymentTypeCodeOf(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasInformationData', () => {
|
||||
it('faux si tous les champs Information sont vides/absents', () => {
|
||||
expect(hasInformationData({ '@id': '/api/clients/1', id: 1 })).toBe(false)
|
||||
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, description: ' ' })).toBe(false)
|
||||
})
|
||||
|
||||
it('vrai des qu\'un champ Information porte une donnee', () => {
|
||||
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, directorName: 'Dupont' })).toBe(true)
|
||||
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, employeesCount: 0 })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAccountingData', () => {
|
||||
it('faux sans champ comptable ni RIB', () => {
|
||||
expect(hasAccountingData({ '@id': '/api/clients/1', id: 1 })).toBe(false)
|
||||
})
|
||||
|
||||
it('vrai avec un champ comptable scalaire', () => {
|
||||
expect(hasAccountingData({ '@id': '/api/clients/1', id: 1, siren: '123456789' })).toBe(true)
|
||||
})
|
||||
|
||||
it('vrai avec une relation comptable embarquee (paymentType)', () => {
|
||||
expect(hasAccountingData({
|
||||
'@id': '/api/clients/1', id: 1,
|
||||
paymentType: { '@id': '/api/payment_types/1', code: 'LCR' },
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('vrai avec au moins un RIB', () => {
|
||||
expect(hasAccountingData({
|
||||
'@id': '/api/clients/1', id: 1,
|
||||
ribs: [{ '@id': '/api/ribs/1', id: 1, iban: 'FR76...' }],
|
||||
})).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clientConsultationVisibleTabs', () => {
|
||||
it('retourne [] tant que le client n\'est pas charge', () => {
|
||||
expect(clientConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
|
||||
expect(clientConsultationVisibleTabs(undefined, { canAccountingView: true })).toEqual([])
|
||||
})
|
||||
|
||||
it('masque les coquilles et les onglets vides (client minimal)', () => {
|
||||
const client: ClientDetail = { '@id': '/api/clients/1', id: 1, companyName: 'ACME' }
|
||||
expect(clientConsultationVisibleTabs(client, { canAccountingView: true })).toEqual([])
|
||||
})
|
||||
|
||||
it('affiche les onglets non vides dans l\'ordre information/contact/address/accounting', () => {
|
||||
const client: ClientDetail = {
|
||||
'@id': '/api/clients/1', id: 1,
|
||||
directorName: 'Dupont',
|
||||
contacts: [{ '@id': '/api/client_contacts/1', id: 1 }],
|
||||
addresses: [{ '@id': '/api/client_addresses/1', id: 1 }],
|
||||
siren: '123456789',
|
||||
}
|
||||
expect(clientConsultationVisibleTabs(client, { canAccountingView: true }))
|
||||
.toEqual(['information', 'contact', 'address', 'accounting'])
|
||||
})
|
||||
|
||||
it('masque Comptabilite sans le droit accounting.view meme si des donnees existent', () => {
|
||||
const client: ClientDetail = {
|
||||
'@id': '/api/clients/1', id: 1,
|
||||
contacts: [{ '@id': '/api/client_contacts/1', id: 1 }],
|
||||
siren: '123456789',
|
||||
}
|
||||
expect(clientConsultationVisibleTabs(client, { canAccountingView: false }))
|
||||
.toEqual(['contact'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
canEditSupplier,
|
||||
categoryOptionsOf,
|
||||
contactOptionsOf,
|
||||
hasAccountingData,
|
||||
hasInformationData,
|
||||
iriOf,
|
||||
mapAccountingDraft,
|
||||
mapAddressToDraft,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
siteOptionsOf,
|
||||
supplierConsultationVisibleTabs,
|
||||
type SupplierDetail,
|
||||
} from '../supplierConsultation'
|
||||
|
||||
@@ -237,3 +240,60 @@ describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', (
|
||||
expect(paymentTypeCodeOf(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasInformationData (fournisseur)', () => {
|
||||
it('faux si tous les champs Information (volume previsionnel inclus) sont vides', () => {
|
||||
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1 })).toBe(false)
|
||||
})
|
||||
|
||||
it('vrai des qu\'un champ Information porte une donnee', () => {
|
||||
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1, directorName: 'Martin' })).toBe(true)
|
||||
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1, volumeForecast: 1200 })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAccountingData (fournisseur)', () => {
|
||||
it('faux sans champ comptable ni RIB', () => {
|
||||
expect(hasAccountingData({ '@id': '/api/suppliers/1', id: 1 })).toBe(false)
|
||||
})
|
||||
|
||||
it('vrai avec un champ comptable ou un RIB', () => {
|
||||
expect(hasAccountingData({ '@id': '/api/suppliers/1', id: 1, siren: '123456789' })).toBe(true)
|
||||
expect(hasAccountingData({
|
||||
'@id': '/api/suppliers/1', id: 1,
|
||||
ribs: [{ '@id': '/api/supplier_ribs/1', id: 1, iban: 'FR76...' }],
|
||||
})).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('supplierConsultationVisibleTabs', () => {
|
||||
it('retourne [] tant que le fournisseur n\'est pas charge', () => {
|
||||
expect(supplierConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
|
||||
})
|
||||
|
||||
it('masque les coquilles et les onglets vides (fournisseur minimal)', () => {
|
||||
expect(supplierConsultationVisibleTabs(
|
||||
{ '@id': '/api/suppliers/1', id: 1, companyName: 'ACME' },
|
||||
{ canAccountingView: true },
|
||||
)).toEqual([])
|
||||
})
|
||||
|
||||
it('affiche information/contacts/addresses/accounting (cles plurielles) dans l\'ordre', () => {
|
||||
const supplier: SupplierDetail = {
|
||||
'@id': '/api/suppliers/1', id: 1,
|
||||
volumeForecast: 1000,
|
||||
contacts: [{ '@id': '/api/supplier_contacts/1', id: 1 }],
|
||||
addresses: [{ '@id': '/api/supplier_addresses/1', id: 1 }],
|
||||
siren: '123456789',
|
||||
}
|
||||
expect(supplierConsultationVisibleTabs(supplier, { canAccountingView: true }))
|
||||
.toEqual(['information', 'contacts', 'addresses', 'accounting'])
|
||||
})
|
||||
|
||||
it('masque Comptabilite sans le droit accounting.view', () => {
|
||||
expect(supplierConsultationVisibleTabs(
|
||||
{ '@id': '/api/suppliers/1', id: 1, siren: '123456789' },
|
||||
{ canAccountingView: false },
|
||||
)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Helpers de saisie des montants des formulaires Client / Fournisseur (commercial).
|
||||
* Purs / testables. Pendant FRONT de la contrainte back `LessThanOrEqual` posee sur
|
||||
* `revenueAmount` (Client/Supplier) — retour metier ERP-193 : le chiffre d'affaires
|
||||
* est plafonne a 999 999 999 999,99.
|
||||
*/
|
||||
|
||||
/** Plafond metier du chiffre d'affaires (CA) : 999 999 999 999,99. */
|
||||
export const REVENUE_AMOUNT_MAX = 999_999_999_999.99
|
||||
|
||||
/** Valeur « modele » (decimale a point, sans separateur) renvoyee quand on plafonne. */
|
||||
const REVENUE_AMOUNT_MAX_MODEL = '999999999999.99'
|
||||
|
||||
/**
|
||||
* Plafonne le CA au maximum metier. Recoit le modele emis par `MalioInputAmount`
|
||||
* (chaine propre a decimale `.`, sans espaces) ; tolere malgre tout une virgule /
|
||||
* des espaces par securite. Renvoie la valeur telle quelle si elle est vide, non
|
||||
* numerique ou sous le plafond ; sinon la valeur plafonnee.
|
||||
*/
|
||||
export function clampRevenueAmount(value: string | null | undefined): string | null | undefined {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return value
|
||||
}
|
||||
const n = Number(String(value).replace(/\s/g, '').replace(',', '.'))
|
||||
if (Number.isNaN(n)) {
|
||||
return value
|
||||
}
|
||||
return n > REVENUE_AMOUNT_MAX ? REVENUE_AMOUNT_MAX_MODEL : value
|
||||
}
|
||||
@@ -317,6 +317,77 @@ export function mapAddressView(address: AddressRead): AddressView {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
|
||||
* et non chaine vide apres trim). Sert aux predicats « onglet vide » ci-dessous.
|
||||
*/
|
||||
function hasValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return false
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.trim() !== ''
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si l'onglet Information porte au moins une donnee. ERP-193 : en
|
||||
* consultation on masque les onglets vides ; Information n'echappe pas a la
|
||||
* regle malgre son statut d'onglet d'atterrissage par defaut.
|
||||
*/
|
||||
export function hasInformationData(client: ClientDetail): boolean {
|
||||
return [
|
||||
client.description,
|
||||
client.competitors,
|
||||
client.foundedAt,
|
||||
client.employeesCount,
|
||||
client.revenueAmount,
|
||||
client.profitAmount,
|
||||
client.directorName,
|
||||
].some(hasValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si l'onglet Comptabilite porte au moins un champ comptable OU un RIB.
|
||||
* (Le gating permission `accounting.view` reste applique en amont par l'appelant.)
|
||||
*/
|
||||
export function hasAccountingData(client: ClientDetail): boolean {
|
||||
const draft = mapAccountingDraft(client)
|
||||
const hasField = Object.values(draft).some(hasValue)
|
||||
const hasRib = (client.ribs ?? []).length > 0
|
||||
return hasField || hasRib
|
||||
}
|
||||
|
||||
/**
|
||||
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
|
||||
* coquilles non implementees (Transport / Statistiques / Rapports / Echanges)
|
||||
* ET tout onglet de donnees vide. L'ordre reproduit `buildClientFormTabKeys`.
|
||||
* Retourne `[]` tant que le client n'est pas charge.
|
||||
*/
|
||||
export function clientConsultationVisibleTabs(
|
||||
client: ClientDetail | null | undefined,
|
||||
options: { canAccountingView: boolean },
|
||||
): string[] {
|
||||
if (!client) {
|
||||
return []
|
||||
}
|
||||
const visible: string[] = []
|
||||
if (hasInformationData(client)) {
|
||||
visible.push('information')
|
||||
}
|
||||
if ((client.contacts ?? []).length > 0) {
|
||||
visible.push('contact')
|
||||
}
|
||||
if ((client.addresses ?? []).length > 0) {
|
||||
visible.push('address')
|
||||
}
|
||||
if (options.canAccountingView && hasAccountingData(client)) {
|
||||
visible.push('accounting')
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
/**
|
||||
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
|
||||
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
|
||||
|
||||
@@ -292,6 +292,78 @@ export function mapAddressView(address: AddressRead): AddressView {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
|
||||
* et non chaine vide apres trim). Sert aux predicats « onglet vide » ci-dessous.
|
||||
*/
|
||||
function hasValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return false
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.trim() !== ''
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si l'onglet Information porte au moins une donnee (volume previsionnel
|
||||
* inclus, specifique fournisseur). ERP-193 : en consultation on masque les
|
||||
* onglets vides, Information comprise.
|
||||
*/
|
||||
export function hasInformationData(supplier: SupplierDetail): boolean {
|
||||
return [
|
||||
supplier.description,
|
||||
supplier.competitors,
|
||||
supplier.foundedAt,
|
||||
supplier.employeesCount,
|
||||
supplier.revenueAmount,
|
||||
supplier.profitAmount,
|
||||
supplier.directorName,
|
||||
supplier.volumeForecast,
|
||||
].some(hasValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si l'onglet Comptabilite porte au moins un champ comptable OU un RIB.
|
||||
* (Le gating permission `accounting.view` reste applique en amont par l'appelant.)
|
||||
*/
|
||||
export function hasAccountingData(supplier: SupplierDetail): boolean {
|
||||
const draft = mapAccountingDraft(supplier)
|
||||
const hasField = Object.values(draft).some(hasValue)
|
||||
const hasRib = (supplier.ribs ?? []).length > 0
|
||||
return hasField || hasRib
|
||||
}
|
||||
|
||||
/**
|
||||
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
|
||||
* coquilles non implementees (Transport / Statistiques / Rapports / Echanges)
|
||||
* ET tout onglet de donnees vide. L'ordre reproduit `buildSupplierFormTabKeys`.
|
||||
* Retourne `[]` tant que le fournisseur n'est pas charge.
|
||||
*/
|
||||
export function supplierConsultationVisibleTabs(
|
||||
supplier: SupplierDetail | null | undefined,
|
||||
options: { canAccountingView: boolean },
|
||||
): string[] {
|
||||
if (!supplier) {
|
||||
return []
|
||||
}
|
||||
const visible: string[] = []
|
||||
if (hasInformationData(supplier)) {
|
||||
visible.push('information')
|
||||
}
|
||||
if ((supplier.contacts ?? []).length > 0) {
|
||||
visible.push('contacts')
|
||||
}
|
||||
if ((supplier.addresses ?? []).length > 0) {
|
||||
visible.push('addresses')
|
||||
}
|
||||
if (options.canAccountingView && hasAccountingData(supplier)) {
|
||||
visible.push('accounting')
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
/**
|
||||
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
|
||||
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- En-tête du bloc : titre + boutons de pesée (bascule / manuelle). -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-m-primary">{{ title }}</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
:label="t('logistique.weighingTickets.form.weighbridge.auto')"
|
||||
:disabled="disabled"
|
||||
@click="$emit('request-auto')"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('logistique.weighingTickets.form.weighbridge.manual')"
|
||||
:disabled="disabled"
|
||||
@click="$emit('request-manual')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Contrepartie : rendue par le parent (bloc vide uniquement) via le slot. -->
|
||||
<slot name="counterparty" />
|
||||
|
||||
<!-- Date de la pesée — jour par défaut (RG-5.07). MalioDate (composant
|
||||
projet pour le type date, exception tolérée @.claude/rules/frontend.md). -->
|
||||
<MalioDate
|
||||
:model-value="block.date"
|
||||
:label="t('logistique.weighingTickets.form.date')"
|
||||
:required="true"
|
||||
:editable="true"
|
||||
:disabled="disabled"
|
||||
:error="errors.date"
|
||||
@update:model-value="(v: string | null) => emitBlock('date', v)"
|
||||
/>
|
||||
|
||||
<!-- Poids : readonly, rempli par la pesée (RG-5.07). Unité Kg dans le label. -->
|
||||
<MalioInputNumber
|
||||
:model-value="block.weight"
|
||||
:label="t('logistique.weighingTickets.form.weight')"
|
||||
:required="true"
|
||||
:readonly="true"
|
||||
:disabled="disabled"
|
||||
:error="errors.weight"
|
||||
/>
|
||||
|
||||
<!-- DSD : readonly, rempli par la pesée (RG-5.04 / RG-5.07). -->
|
||||
<MalioInputNumber
|
||||
:model-value="block.dsd"
|
||||
:label="t('logistique.weighingTickets.form.dsd')"
|
||||
:required="true"
|
||||
:readonly="true"
|
||||
:disabled="disabled"
|
||||
:error="errors.dsd"
|
||||
/>
|
||||
|
||||
<!-- Immatriculation : masque XX-000-XX (plaque FR SIV) sauf « Tout format ».
|
||||
PARTAGÉE entre les 2 blocs (RG-5.01) — v-model remonté au form parent.
|
||||
TODO migrer le masque plaque quand @malio/layer-ui couvrira le format. -->
|
||||
<MalioInputText
|
||||
:model-value="immatriculation"
|
||||
:mask="plateFreeFormat ? undefined : PLATE_MASK"
|
||||
:label="t('logistique.weighingTickets.form.immatriculation')"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:error="errors.immatriculation"
|
||||
@update:model-value="(v: string | null) => $emit('update:immatriculation', v)"
|
||||
/>
|
||||
|
||||
<!-- « Tout format » : désactive le masque plaque. Partagé entre blocs (RG-5.01). -->
|
||||
<MalioCheckbox
|
||||
:id="`${blockId}-plate-free-format`"
|
||||
:model-value="plateFreeFormat"
|
||||
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
|
||||
group-class="self-center"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: boolean) => $emit('update:plateFreeFormat', v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||
|
||||
/**
|
||||
* Bloc de pesée (« Poids à vide » ou « Poids à plein ») de l'écran Ticket de pesée.
|
||||
* Champs Date / Poids / DSD / Immatriculation / « Tout format » + boutons de pesée.
|
||||
* L'immatriculation et « Tout format » sont PARTAGÉS entre les 2 blocs (RG-5.01) :
|
||||
* portés par le form parent et remontés en `update:*`. Le slot `counterparty`
|
||||
* permet au parent d'injecter la contrepartie sur le seul bloc vide (RG-5.03).
|
||||
*/
|
||||
|
||||
// Masque plaque FR SIV `XX-000-XX` (maska) : 2 lettres, 3 chiffres, 2 lettres,
|
||||
// majuscules forcées. Désactivé quand « Tout format » est coché (RG-5.01).
|
||||
const PLATE_MASK = {
|
||||
mask: 'AA-###-AA',
|
||||
tokens: { A: { pattern: /[A-Za-z]/, transform: (c: string) => c.toUpperCase() } },
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
/** Identifiant technique du bloc (pour les `id` de champs uniques). */
|
||||
blockId: string
|
||||
title: string
|
||||
block: WeighingBlockState
|
||||
/** Immatriculation partagée (RG-5.01) — portée par le form parent. */
|
||||
immatriculation: string | null
|
||||
/** « Tout format » partagé (RG-5.01) — porté par le form parent. */
|
||||
plateFreeFormat: boolean
|
||||
/** Erreurs 422 par champ (propertyPath → message). */
|
||||
errors?: Record<string, string>
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:block': [field: keyof WeighingBlockState, value: unknown]
|
||||
'update:immatriculation': [value: string | null]
|
||||
'update:plateFreeFormat': [value: boolean]
|
||||
'request-auto': []
|
||||
'request-manual': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const errors = computed(() => props.errors ?? {})
|
||||
|
||||
/** Remonte la mutation d'un champ du bloc au parent (état des pesées centralisé). */
|
||||
function emitBlock(field: keyof WeighingBlockState, value: unknown): void {
|
||||
emit('update:block', field, value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// useApi / useI18n sont des auto-imports Nuxt : on les expose en globals.
|
||||
const mockPost = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({ post: mockPost }))
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
|
||||
const { useWeighbridge } = await import('../useWeighbridge')
|
||||
|
||||
describe('useWeighbridge', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
})
|
||||
|
||||
it('AUTO : POST { mode: AUTO } sans toast et renvoie la lecture', async () => {
|
||||
mockPost.mockResolvedValue({ weight: 23187, dsd: 42, mode: 'AUTO' })
|
||||
const { triggerAuto } = useWeighbridge()
|
||||
|
||||
const reading = await triggerAuto()
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/weighbridge_readings',
|
||||
{ mode: 'AUTO' },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
expect(reading).toEqual({ weight: 23187, dsd: 42, mode: 'AUTO' })
|
||||
})
|
||||
|
||||
it('MANUAL : POST { mode: MANUAL, weight, manualNumber } et renvoie la lecture', async () => {
|
||||
mockPost.mockResolvedValue({ weight: 5000, dsd: 43, manualNumber: 'PAP-555', mode: 'MANUAL' })
|
||||
const { triggerManual } = useWeighbridge()
|
||||
|
||||
const reading = await triggerManual(5000, 'PAP-555')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/weighbridge_readings',
|
||||
{ mode: 'MANUAL', weight: 5000, manualNumber: 'PAP-555' },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
expect(reading.dsd).toBe(43)
|
||||
})
|
||||
|
||||
it('erreur (RG-5.06) : extractWeighbridgeError privilégie le detail du 503', () => {
|
||||
const { extractWeighbridgeError } = useWeighbridge()
|
||||
const error = { response: { status: 503, _data: { title: 'Pont bascule indisponible', detail: 'Passez en pesée manuelle.' } } }
|
||||
expect(extractWeighbridgeError(error)).toBe('Passez en pesée manuelle.')
|
||||
})
|
||||
|
||||
it('erreur sans payload exploitable : retombe sur le libellé i18n générique', () => {
|
||||
const { extractWeighbridgeError } = useWeighbridge()
|
||||
expect(extractWeighbridgeError(new Error('network')))
|
||||
.toBe('logistique.weighingTickets.form.weighbridge.unavailable')
|
||||
})
|
||||
|
||||
it('triggerAuto propage l\'erreur API (gestion par l\'écran)', async () => {
|
||||
mockPost.mockRejectedValue({ response: { status: 503 } })
|
||||
const { triggerAuto } = useWeighbridge()
|
||||
await expect(triggerAuto()).rejects.toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// `todayIso` est importé par le composable : on le stubbe pour une date déterministe.
|
||||
vi.mock('~/shared/utils/date', () => ({ todayIso: () => '2026-06-22' }))
|
||||
|
||||
const { useWeighingTicketForm } = await import('../useWeighingTicketForm')
|
||||
|
||||
describe('useWeighingTicketForm', () => {
|
||||
it('initialise les 2 blocs à la date du jour (RG-5.07), sans poids ni DSD', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
expect(form.empty.date).toBe('2026-06-22')
|
||||
expect(form.full.date).toBe('2026-06-22')
|
||||
expect(form.empty.weight).toBeNull()
|
||||
expect(form.empty.dsd).toBeNull()
|
||||
expect(form.counterpartyType.value).toBeNull()
|
||||
})
|
||||
|
||||
// ── Contrepartie conditionnelle (RG-5.03) ────────────────────────────────
|
||||
it('CLIENT : ne conserve que le client, purge supplier et otherLabel', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
form.supplierIri.value = '/api/suppliers/3'
|
||||
form.otherLabel.value = 'Particulier'
|
||||
|
||||
form.setCounterpartyType('CLIENT')
|
||||
form.clientIri.value = '/api/clients/629'
|
||||
|
||||
expect(form.counterpartyField.value).toBe('client')
|
||||
expect(form.supplierIri.value).toBeNull()
|
||||
expect(form.otherLabel.value).toBeNull()
|
||||
|
||||
const payload = form.buildCreatePayload()
|
||||
expect(payload.counterpartyType).toBe('CLIENT')
|
||||
expect(payload.client).toBe('/api/clients/629')
|
||||
expect(payload).not.toHaveProperty('supplier')
|
||||
expect(payload).not.toHaveProperty('otherLabel')
|
||||
})
|
||||
|
||||
it('FOURNISSEUR : ne conserve que le supplier', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
form.clientIri.value = '/api/clients/1'
|
||||
form.setCounterpartyType('FOURNISSEUR')
|
||||
form.supplierIri.value = '/api/suppliers/7'
|
||||
|
||||
expect(form.counterpartyField.value).toBe('supplier')
|
||||
expect(form.clientIri.value).toBeNull()
|
||||
expect(form.buildCreatePayload().supplier).toBe('/api/suppliers/7')
|
||||
})
|
||||
|
||||
it('AUTRE : ne conserve que le libellé libre', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
form.clientIri.value = '/api/clients/1'
|
||||
form.setCounterpartyType('AUTRE')
|
||||
form.otherLabel.value = 'Reprise interne'
|
||||
|
||||
expect(form.counterpartyField.value).toBe('other')
|
||||
expect(form.clientIri.value).toBeNull()
|
||||
expect(form.buildCreatePayload().otherLabel).toBe('Reprise interne')
|
||||
})
|
||||
|
||||
// ── Immatriculation / « Tout format » partagés entre blocs (RG-5.01) ──────
|
||||
it('immatriculation et plateFreeFormat sont partagés (une seule valeur)', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
form.immatriculation.value = 'AB-123-CD'
|
||||
form.plateFreeFormat.value = true
|
||||
|
||||
// Les 2 payloads (création + finalisation) reflètent la même valeur.
|
||||
expect(form.buildCreatePayload().immatriculation).toBe('AB-123-CD')
|
||||
expect(form.buildCreatePayload().plateFreeFormat).toBe(true)
|
||||
expect(form.buildFullPayload().immatriculation).toBe('AB-123-CD')
|
||||
expect(form.buildFullPayload().plateFreeFormat).toBe(true)
|
||||
})
|
||||
|
||||
// ── Application d'une lecture de pesée ────────────────────────────────────
|
||||
it('applyReading remplit poids / DSD / mode du bloc visé', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
form.applyReading(form.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
|
||||
expect(form.empty.weight).toBe(7150)
|
||||
expect(form.empty.dsd).toBe(1)
|
||||
expect(form.empty.mode).toBe('AUTO')
|
||||
expect(form.empty.manualNumber).toBeNull()
|
||||
|
||||
form.applyReading(form.full, { weight: 14300, dsd: 2, mode: 'MANUAL', manualNumber: 'PAP-555' })
|
||||
expect(form.full.weight).toBe(14300)
|
||||
expect(form.full.manualNumber).toBe('PAP-555')
|
||||
})
|
||||
|
||||
it('buildCreatePayload porte la pesée à vide, buildFullPayload la pesée à plein', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
form.setCounterpartyType('CLIENT')
|
||||
form.clientIri.value = '/api/clients/1'
|
||||
form.applyReading(form.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
|
||||
form.applyReading(form.full, { weight: 14300, dsd: 2, mode: 'AUTO' })
|
||||
|
||||
const create = form.buildCreatePayload()
|
||||
expect(create.emptyWeight).toBe(7150)
|
||||
expect(create.emptyDsd).toBe(1)
|
||||
expect(create.emptyMode).toBe('AUTO')
|
||||
expect(create).not.toHaveProperty('fullWeight')
|
||||
|
||||
const full = form.buildFullPayload()
|
||||
expect(full.fullWeight).toBe(14300)
|
||||
expect(full.fullDsd).toBe(2)
|
||||
expect(full.fullMode).toBe('AUTO')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Pesée au pont bascule (M5, ERP-189) — déclenche une lecture de poids via
|
||||
* `POST /api/weighbridge_readings` (spec-back § 4.2). Action autonome : le ticket
|
||||
* n'existe pas encore quand on pèse depuis le formulaire principal.
|
||||
*
|
||||
* Deux modes :
|
||||
* - AUTO (« Pesée bascule ») : le serveur résout le site courant, lit le poids
|
||||
* (stub aléatoire au M5) et alloue le DSD. Peut échouer (RG-5.06 → 503) : le
|
||||
* pont est indisponible, on invite l'utilisateur à passer en pesée manuelle.
|
||||
* - MANUAL (« Pesée manuelle ») : poids + numéro de pesée saisis ; le serveur
|
||||
* calcule le DSD = dernier + 1 (RG-5.04).
|
||||
*
|
||||
* Composable UI-agnostique : il appelle l'API (`useApi`, jamais `$fetch`) et
|
||||
* renvoie la lecture, ou lève l'erreur — la gestion de la modal/de l'affichage
|
||||
* reste à la charge de l'écran. `extractWeighbridgeError` factorise la lecture
|
||||
* du message d'erreur 503 (RG-5.06) pour l'afficher dans la modal.
|
||||
*/
|
||||
|
||||
/** Mode de pesée — miroir de l'enum back. */
|
||||
export type WeighbridgeMode = 'AUTO' | 'MANUAL'
|
||||
|
||||
/** Lecture renvoyée par le pont bascule (spec-back § 4.2). */
|
||||
export interface WeighbridgeReading {
|
||||
weight: number
|
||||
dsd: number
|
||||
mode: WeighbridgeMode
|
||||
/** Numéro de pesée saisi en mode MANUAL (absent en AUTO). */
|
||||
manualNumber?: string
|
||||
}
|
||||
|
||||
export function useWeighbridge() {
|
||||
const api = useApi()
|
||||
const { t } = useI18n()
|
||||
|
||||
/**
|
||||
* Pesée bascule (AUTO). Le site courant est résolu serveur — rien à envoyer.
|
||||
* `toast: false` : l'erreur (RG-5.06) est affichée inline dans la modal, pas
|
||||
* en toast global.
|
||||
*/
|
||||
async function triggerAuto(): Promise<WeighbridgeReading> {
|
||||
return await api.post<WeighbridgeReading>(
|
||||
'/weighbridge_readings',
|
||||
{ mode: 'AUTO' },
|
||||
{ toast: false },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pesée manuelle (MANUAL). Le DSD est calculé serveur (dernier + 1, RG-5.04) ;
|
||||
* le `manualNumber` est la référence du ticket papier / autre bascule.
|
||||
*/
|
||||
async function triggerManual(weight: number, manualNumber: string): Promise<WeighbridgeReading> {
|
||||
return await api.post<WeighbridgeReading>(
|
||||
'/weighbridge_readings',
|
||||
{ mode: 'MANUAL', weight, manualNumber },
|
||||
{ toast: false },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Message d'erreur de pesée bascule (RG-5.06). Le back renvoie un 503
|
||||
* `{ title, detail }` (« Pont bascule indisponible » / « Passez en pesée
|
||||
* manuelle. ») — on privilégie le `detail`, puis le `title`, sinon un libellé
|
||||
* générique invitant à la pesée manuelle.
|
||||
*/
|
||||
function extractWeighbridgeError(error: unknown): string {
|
||||
const data = (error as { response?: { _data?: unknown } })?.response?._data as
|
||||
| { detail?: string, title?: string }
|
||||
| undefined
|
||||
return data?.detail || data?.title || t('logistique.weighingTickets.form.weighbridge.unavailable')
|
||||
}
|
||||
|
||||
return { triggerAuto, triggerManual, extractWeighbridgeError }
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { todayIso } from '~/shared/utils/date'
|
||||
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
|
||||
|
||||
/**
|
||||
* État et logique du formulaire « Ajouter / Modifier un ticket de pesée » (M5,
|
||||
* ERP-189). L'écran est composé de DEUX blocs empilés — pesée à vide puis pesée
|
||||
* à plein — qui partagent un même véhicule.
|
||||
*
|
||||
* Points clés (spec-front § Écran Ajouter, spec-back § 2.4 / 2.9 / 2.10) :
|
||||
* - **Contrepartie conditionnelle (RG-5.03)** : `counterpartyType` (CLIENT /
|
||||
* FOURNISSEUR / AUTRE) pilote le champ requis (client / supplier / otherLabel).
|
||||
* Changer de type purge les champs des autres types — aucune donnée fantôme.
|
||||
* - **Immatriculation + « Tout format » partagés entre les 2 blocs (RG-5.01)** :
|
||||
* une seule valeur (refs uniques) — modifier l'un met à jour l'autre puisque
|
||||
* les 2 blocs bindent la même ref.
|
||||
* - **Workflow 2 temps** : `buildCreatePayload()` (POST à l'« Enregistrer » du
|
||||
* bloc vide) crée le ticket avec la pesée à vide ; `buildFullPayload()` (PATCH
|
||||
* au « Valider ») ajoute la pesée à plein (net recalculé serveur, RG-5.05).
|
||||
*
|
||||
* Composable UI-agnostique et testable : aucune dépendance API ici (les appels
|
||||
* vivent dans l'écran via `useApi`). Instancié PAR écran (refs locales).
|
||||
*/
|
||||
|
||||
/** Type de contrepartie — miroir de l'enum back (spec-back § 2.9). */
|
||||
export type CounterpartyType = 'CLIENT' | 'FOURNISSEUR' | 'AUTRE'
|
||||
|
||||
/** Saisie d'une pesée (bloc vide OU bloc plein). */
|
||||
export interface WeighingBlockState {
|
||||
/** Date de la pesée (ISO `YYYY-MM-DD`) — jour par défaut (RG-5.07). */
|
||||
date: string | null
|
||||
/** Poids en kg — readonly, rempli par la pesée (bascule ou manuelle). */
|
||||
weight: number | null
|
||||
/** DSD — readonly, rempli par la pesée (RG-5.04). */
|
||||
dsd: number | null
|
||||
/** Mode de la dernière pesée appliquée au bloc. */
|
||||
mode: WeighbridgeMode | null
|
||||
/** Numéro de pesée (rempli uniquement en pesée manuelle). */
|
||||
manualNumber: string | null
|
||||
}
|
||||
|
||||
/** Crée l'état initial d'un bloc de pesée (date = aujourd'hui, RG-5.07). */
|
||||
function emptyBlock(today: string): WeighingBlockState {
|
||||
return {
|
||||
date: today,
|
||||
weight: null,
|
||||
dsd: null,
|
||||
mode: null,
|
||||
manualNumber: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function useWeighingTicketForm() {
|
||||
const today = todayIso()
|
||||
|
||||
// ── Contrepartie (RG-5.03) ───────────────────────────────────────────────
|
||||
const counterpartyType = ref<CounterpartyType | null>(null)
|
||||
const clientIri = ref<string | null>(null)
|
||||
const supplierIri = ref<string | null>(null)
|
||||
const otherLabel = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Change le type de contrepartie et purge les champs devenus hors-sujet :
|
||||
* un seul de client / supplier / otherLabel est conservé selon le type
|
||||
* (RG-5.03 — pas de FK fantôme envoyée au back).
|
||||
*/
|
||||
function setCounterpartyType(type: CounterpartyType | null): void {
|
||||
counterpartyType.value = type
|
||||
if (type !== 'CLIENT') clientIri.value = null
|
||||
if (type !== 'FOURNISSEUR') supplierIri.value = null
|
||||
if (type !== 'AUTRE') otherLabel.value = null
|
||||
}
|
||||
|
||||
// ── Véhicule : partagé entre les 2 blocs (RG-5.01) ────────────────────────
|
||||
// Refs UNIQUES : les 2 blocs bindent la même valeur → connexion automatique.
|
||||
const immatriculation = ref<string | null>(null)
|
||||
const plateFreeFormat = ref<boolean>(false)
|
||||
|
||||
// ── Les deux pesées ───────────────────────────────────────────────────────
|
||||
const empty = reactive<WeighingBlockState>(emptyBlock(today))
|
||||
const full = reactive<WeighingBlockState>(emptyBlock(today))
|
||||
|
||||
// Id du ticket créé (POST du bloc vide) — pilote le PATCH du bloc plein.
|
||||
const ticketId = ref<number | null>(null)
|
||||
|
||||
/**
|
||||
* Champ de contrepartie attendu selon le type courant — utilisé par l'écran
|
||||
* pour afficher conditionnellement le bon champ (RG-5.03).
|
||||
*/
|
||||
const counterpartyField = computed<'client' | 'supplier' | 'other' | null>(() => {
|
||||
switch (counterpartyType.value) {
|
||||
case 'CLIENT': return 'client'
|
||||
case 'FOURNISSEUR': return 'supplier'
|
||||
case 'AUTRE': return 'other'
|
||||
default: return null
|
||||
}
|
||||
})
|
||||
|
||||
/** Applique une lecture de pesée (bascule/manuelle) à un bloc. */
|
||||
function applyReading(
|
||||
block: WeighingBlockState,
|
||||
reading: { weight: number, dsd: number, mode: WeighbridgeMode, manualNumber?: string },
|
||||
): void {
|
||||
block.weight = reading.weight
|
||||
block.dsd = reading.dsd
|
||||
block.mode = reading.mode
|
||||
block.manualNumber = reading.manualNumber ?? null
|
||||
}
|
||||
|
||||
/** Partie « contrepartie » du payload (FK en IRI ou libellé libre). */
|
||||
function counterpartyPayload(): Record<string, unknown> {
|
||||
switch (counterpartyType.value) {
|
||||
case 'CLIENT': return { client: clientIri.value }
|
||||
case 'FOURNISSEUR': return { supplier: supplierIri.value }
|
||||
case 'AUTRE': return { otherLabel: otherLabel.value || null }
|
||||
default: return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload de CRÉATION (POST /weighing_tickets, spec-back § 4.3) : contrepartie
|
||||
* + véhicule + pesée à VIDE. Le numéro, le site et le net sont attribués
|
||||
* serveur (rien à envoyer). Les noms de champs miroir des `propertyPath` back
|
||||
* pour que `useFormErrors` mappe les 422 inline.
|
||||
*/
|
||||
function buildCreatePayload(): Record<string, unknown> {
|
||||
return {
|
||||
counterpartyType: counterpartyType.value,
|
||||
...counterpartyPayload(),
|
||||
immatriculation: immatriculation.value || null,
|
||||
plateFreeFormat: plateFreeFormat.value,
|
||||
emptyDate: empty.date || null,
|
||||
emptyWeight: empty.weight,
|
||||
emptyDsd: empty.dsd,
|
||||
emptyMode: empty.mode,
|
||||
emptyManualNumber: empty.manualNumber || null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload de FINALISATION (PATCH /weighing_tickets/{id}, spec-back § 4.4) :
|
||||
* pesée à PLEIN. Le véhicule (immat / tout format) peut avoir été ajusté entre
|
||||
* les 2 blocs → on le repousse aussi (valeur partagée, RG-5.01). Le net est
|
||||
* recalculé serveur (RG-5.05).
|
||||
*/
|
||||
function buildFullPayload(): Record<string, unknown> {
|
||||
return {
|
||||
immatriculation: immatriculation.value || null,
|
||||
plateFreeFormat: plateFreeFormat.value,
|
||||
fullDate: full.date || null,
|
||||
fullWeight: full.weight,
|
||||
fullDsd: full.dsd,
|
||||
fullMode: full.mode,
|
||||
fullManualNumber: full.manualNumber || null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// contrepartie
|
||||
counterpartyType,
|
||||
counterpartyField,
|
||||
clientIri,
|
||||
supplierIri,
|
||||
otherLabel,
|
||||
setCounterpartyType,
|
||||
// véhicule partagé
|
||||
immatriculation,
|
||||
plateFreeFormat,
|
||||
// pesées
|
||||
empty,
|
||||
full,
|
||||
applyReading,
|
||||
// workflow
|
||||
ticketId,
|
||||
buildCreatePayload,
|
||||
buildFullPayload,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Référentiels alimentant les selects de contrepartie de l'écran « Ticket de
|
||||
* pesée » (M5, ERP-189) : liste des clients (M1) et des fournisseurs (M2).
|
||||
*
|
||||
* Collections récupérées en entier via l'échappatoire `?pagination=false`
|
||||
* (référentiels de quelques dizaines d'entrées), avec l'en-tête
|
||||
* `Accept: application/ld+json` imposé par API Platform 4 pour obtenir
|
||||
* l'enveloppe Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`) —
|
||||
* renvoyée telle quelle dans le payload POST/PATCH (relation ManyToOne).
|
||||
*
|
||||
* Miroir de `useClientReferentials` (M1). État 100 % local à l'instance.
|
||||
*/
|
||||
|
||||
/** Option au format attendu par MalioSelect ({ label, value }). */
|
||||
export interface RefOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface PartyMember {
|
||||
'@id': string
|
||||
companyName: string
|
||||
}
|
||||
|
||||
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||
|
||||
export function useWeighingTicketReferentials() {
|
||||
const api = useApi()
|
||||
|
||||
const clients = ref<RefOption[]>([])
|
||||
const suppliers = ref<RefOption[]>([])
|
||||
|
||||
/** Récupère une collection complète (pagination désactivée) en Hydra. */
|
||||
async function fetchAll(url: string): Promise<PartyMember[]> {
|
||||
const res = await api.get<{ member?: PartyMember[] }>(
|
||||
url,
|
||||
{ pagination: 'false' },
|
||||
{ headers: LD_JSON_HEADERS, toast: false },
|
||||
)
|
||||
return res.member ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge en parallèle clients + fournisseurs (résilient : un référentiel en
|
||||
* échec — ex. 403 selon le rôle — laisse simplement son select vide sans
|
||||
* faire échouer l'autre).
|
||||
*/
|
||||
async function load(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
fetchAll('/clients').then((list) => {
|
||||
clients.value = list.map(c => ({ value: c['@id'], label: c.companyName }))
|
||||
}),
|
||||
fetchAll('/suppliers').then((list) => {
|
||||
suppliers.value = list.map(s => ({ value: s['@id'], label: s.companyName }))
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
return { clients, suppliers, load }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||
|
||||
/**
|
||||
* Vue MINIMALE d'une contrepartie embarquee (Client M1 ou Fournisseur M2) dans la
|
||||
* LISTE des tickets de pesee. Seul `companyName` alimente les colonnes
|
||||
* « Client » / « Fournisseur » ; l'objet sort embarque (`client:read` /
|
||||
* `supplier:read`) ou est carrement absent du JSON quand null (`skip_null_values`,
|
||||
* spec-back § 4.0.bis) — d'ou le `?? null` systematique cote page.
|
||||
*/
|
||||
export interface WeighingTicketParty {
|
||||
id: number
|
||||
companyName: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue MINIMALE d'un ticket de pesee pour la datatable (M5, ERP-188). Volontairement
|
||||
* partielle : seuls les champs des colonnes (docx p.3) + l'id (navigation) sont
|
||||
* types. Le detail complet (pesees vide/plein, immatriculation, site, DSD) releve
|
||||
* de l'ecran Modification (ERP-190) — hors perimetre de cet ecran.
|
||||
*
|
||||
* Contrepartie mutuellement exclusive (RG-5.03) : un seul de `client` / `supplier`
|
||||
* / `otherLabel` est renseigne ; les deux autres sont omis du JSON (null).
|
||||
* `displayDate` = getter serveur `fullDate ?? emptyDate` (spec-back § 4.0).
|
||||
* `netWeight` = plein − vide en kg (RG-5.05).
|
||||
*/
|
||||
export interface WeighingTicket {
|
||||
id: number
|
||||
/** Numero metier `{siteCode}-TP-{NNNN}` attribue par site (RG-5.02). */
|
||||
number: string
|
||||
/** Embarque uniquement si contrepartie = Client (RG-5.03), sinon absent. */
|
||||
client: WeighingTicketParty | null
|
||||
/** Embarque uniquement si contrepartie = Fournisseur (RG-5.03), sinon absent. */
|
||||
supplier: WeighingTicketParty | null
|
||||
/** Libelle libre si contrepartie = Autre (RG-5.03), sinon absent. */
|
||||
otherLabel: string | null
|
||||
/** Date ISO du ticket (`fullDate ?? emptyDate`) — colonne « Date ». */
|
||||
displayDate: string | null
|
||||
/** Poids net en kg (= plein − vide, RG-5.05) — colonne « Poids ». */
|
||||
netWeight: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtres de la liste des tickets de pesee, branches sur les query params de
|
||||
* `GET /api/weighing_tickets` (spec-back § 4.1). La liste est par ailleurs
|
||||
* cloisonnee par site courant cote back (`SiteScopedQueryExtension`, § 2.3) — le
|
||||
* front n'a pas a envoyer le site.
|
||||
*/
|
||||
export interface WeighingTicketFilters {
|
||||
search?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des tickets de pesee (M5, ERP-188) — simple enveloppe de
|
||||
* `usePaginatedList<WeighingTicket>` sur la ressource `/weighing_tickets`
|
||||
* (URL API en snake_case ; la route Nuxt reste `/weighing-tickets`). Pagination
|
||||
* serveur obligatoire (regle ABSOLUE n°13), etat 100 % local (regle ABSOLUE n°6).
|
||||
*
|
||||
* Miroir de `useCarriersRepository` (M4). Volontairement PAR INSTANCE (pas de
|
||||
* singleton) : l'etat tableau est propre a l'ecran et meurt avec lui.
|
||||
*/
|
||||
export function useWeighingTicketsRepository() {
|
||||
return usePaginatedList<WeighingTicket, WeighingTicketFilters>({ url: '/weighing_tickets' })
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
|
||||
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||
// La page ne les importe pas (auto-import) : on les expose en globals pour le
|
||||
// runtime de test (happy-dom). Meme philosophie que les specs M1→M4.
|
||||
const mockPush = vi.hoisted(() => vi.fn())
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
const mockCan = vi.hoisted(() => vi.fn())
|
||||
const mockFetch = vi.hoisted(() => vi.fn())
|
||||
const mockToastError = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('useHead', () => undefined)
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
|
||||
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||
|
||||
// Le repository est lui aussi un auto-import : on controle les items renvoyes.
|
||||
// Contrepartie CLIENT (RG-5.03) → supplier / otherLabel absents (skip_null_values).
|
||||
vi.stubGlobal('useWeighingTicketsRepository', () => ({
|
||||
items: ref([
|
||||
{
|
||||
id: 9,
|
||||
number: '86-TP-0001',
|
||||
client: { id: 629, companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' },
|
||||
supplier: null,
|
||||
otherLabel: null,
|
||||
displayDate: '2026-06-17T09:12:00+02:00',
|
||||
netWeight: 7150,
|
||||
},
|
||||
]),
|
||||
totalItems: ref(1),
|
||||
currentPage: ref(1),
|
||||
itemsPerPage: ref(10),
|
||||
itemsPerPageOptions: ref([10, 25, 50]),
|
||||
fetch: mockFetch,
|
||||
goToPage: vi.fn(),
|
||||
setItemsPerPage: vi.fn(),
|
||||
setFilters: vi.fn(),
|
||||
}))
|
||||
|
||||
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
|
||||
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
|
||||
globalThis.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
|
||||
const WeighingTicketsIndex = (await import('../weighing-tickets/index.vue')).default
|
||||
|
||||
// ── Stubs de composants ──────────────────────────────────────────────────────
|
||||
const ButtonStub = defineComponent({
|
||||
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||
emits: ['click'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||
},
|
||||
})
|
||||
|
||||
// Capture les `items` (rows) passes par la page : on rend chaque ligne avec ses
|
||||
// cellules formatees (date / poids) pour pouvoir asserter le mapping des colonnes.
|
||||
const capturedRows = ref<Array<Record<string, unknown>>>([])
|
||||
const DataTableStub = defineComponent({
|
||||
props: { items: { type: Array, default: () => [] } },
|
||||
emits: ['row-click', 'update:page', 'update:per-page'],
|
||||
setup(props, { emit }) {
|
||||
return () => {
|
||||
capturedRows.value = props.items as Array<Record<string, unknown>>
|
||||
return h('div', { 'data-testid': 'datatable' },
|
||||
(props.items as Array<Record<string, unknown>>).map(it =>
|
||||
h('tr', { 'data-row-id': it.id as number, onClick: () => emit('row-click', it) }, [
|
||||
h('td', { 'data-cell': 'displayDate' }, it.displayDate as string),
|
||||
h('td', { 'data-cell': 'netWeight' }, it.netWeight as string),
|
||||
h('td', { 'data-cell': 'client' }, it.client as string),
|
||||
h('td', { 'data-cell': 'supplier' }, it.supplier as string),
|
||||
]),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const PageHeaderStub = defineComponent({
|
||||
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
|
||||
})
|
||||
|
||||
function mountPage() {
|
||||
return mount(WeighingTicketsIndex, {
|
||||
global: {
|
||||
stubs: {
|
||||
PageHeader: PageHeaderStub,
|
||||
MalioButton: ButtonStub,
|
||||
MalioDataTable: DataTableStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('Liste des tickets de pesée (page /weighing-tickets)', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockReset()
|
||||
mockApiGet.mockReset().mockResolvedValue(new Blob())
|
||||
mockCan.mockReset().mockReturnValue(true)
|
||||
mockFetch.mockReset()
|
||||
mockToastError.mockReset()
|
||||
capturedRows.value = []
|
||||
})
|
||||
|
||||
it('charge la liste au montage', async () => {
|
||||
mountPage()
|
||||
await flushPromises()
|
||||
expect(mockFetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('formate la date au format JJ-MM-AAAA', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-cell="displayDate"]').text()).toBe('17-06-2026')
|
||||
})
|
||||
|
||||
it('formate le poids net en kg avec separateur de milliers', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-cell="netWeight"]').text()).toBe('7 150 Kg')
|
||||
})
|
||||
|
||||
it('mappe la contrepartie Client (supplier vide car contrepartie ≠ Fournisseur)', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-cell="client"]').text()).toBe('NÉGOCE MÉTAUX ATLANTIQUE')
|
||||
expect(wrapper.find('[data-cell="supplier"]').text()).toBe('')
|
||||
})
|
||||
|
||||
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
|
||||
mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.manage')
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
|
||||
mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.view')
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('navigue vers la modification au clic sur une ligne', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
await wrapper.find('tr[data-row-id="9"]').trigger('click')
|
||||
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets/9/edit')
|
||||
})
|
||||
|
||||
it('appelle l\'export XLSX sur /weighing_tickets/export.xlsx en blob', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-label="logistique.weighingTickets.export"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
'/weighing_tickets/export.xlsx',
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ responseType: 'blob', toast: false }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('logistique.weighingTickets.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
variant="secondary"
|
||||
:label="t('logistique.weighingTickets.add')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="goToCreate"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Datatable branchee sur usePaginatedList via useWeighingTicketsRepository :
|
||||
pagination serveur (defaut 10), tri number DESC par defaut (cote back),
|
||||
liste cloisonnee par site courant (spec-back § 2.3). Etat 100 % local
|
||||
(regle ABSOLUE n°6). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
row-clickable
|
||||
:empty-message="t('logistique.weighingTickets.empty')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
/>
|
||||
|
||||
<div class="flex justify-center mt-4">
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="primary"
|
||||
:label="t('logistique.weighingTickets.export')"
|
||||
:disabled="exporting"
|
||||
@click="exportXlsx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('logistique.weighingTickets.title') })
|
||||
|
||||
// Bouton « + Ajouter » reserve a `manage` (Admin / Bureau / Usine). « Exporter »
|
||||
// suit `view`. Compta et Commerciale n'ont aucun acces (item sidebar masque cote
|
||||
// back) — spec-front § Acces.
|
||||
const canManage = computed(() => can('logistique.weighing_tickets.manage'))
|
||||
const canView = computed(() => can('logistique.weighing_tickets.view'))
|
||||
|
||||
const {
|
||||
items: tickets,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadTickets,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
} = useWeighingTicketsRepository()
|
||||
|
||||
// Mappe les tickets en objets « plats » formates pour MalioDataTable (items typees
|
||||
// Record<string, unknown>[]). La contrepartie est mutuellement exclusive (RG-5.03) :
|
||||
// une seule des colonnes client / supplier / otherLabel est renseignee, les autres
|
||||
// restent vides. Date et poids sont formates ici (cf. helpers ci-dessous).
|
||||
const rows = computed(() => tickets.value.map(ticket => ({
|
||||
id: ticket.id,
|
||||
number: ticket.number,
|
||||
client: ticket.client?.companyName ?? '',
|
||||
supplier: ticket.supplier?.companyName ?? '',
|
||||
otherLabel: ticket.otherLabel ?? '',
|
||||
displayDate: formatDateFr(ticket.displayDate),
|
||||
netWeight: formatWeight(ticket.netWeight),
|
||||
})))
|
||||
|
||||
const columns = [
|
||||
{ key: 'number', label: t('logistique.weighingTickets.column.number') },
|
||||
{ key: 'client', label: t('logistique.weighingTickets.column.client') },
|
||||
{ key: 'supplier', label: t('logistique.weighingTickets.column.supplier') },
|
||||
{ key: 'otherLabel', label: t('logistique.weighingTickets.column.other') },
|
||||
{ key: 'displayDate', label: t('logistique.weighingTickets.column.date') },
|
||||
{ key: 'netWeight', label: t('logistique.weighingTickets.column.weight') },
|
||||
]
|
||||
|
||||
/** Format court francais JJ-MM-AAAA (spec M5). 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()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Poids net affiche en kg avec separateur de milliers (espace) + suffixe « Kg »
|
||||
* (spec-front § formatage : « 7 150 Kg »). Chaine vide si poids absent (ticket
|
||||
* dont la pesee a plein n'est pas encore finalisee). Groupement manuel (espace
|
||||
* ASCII) pour un rendu deterministe, independant de l'ICU de l'environnement.
|
||||
*/
|
||||
function formatWeight(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
const grouped = String(Math.round(value)).replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
||||
return `${grouped} Kg`
|
||||
}
|
||||
|
||||
/** Clic sur une ligne → ecran Modification (pas de consultation separee, spec § Navigation). */
|
||||
function onRowClick(item: Record<string, unknown>): void {
|
||||
router.push(`/weighing-tickets/${item.id}/edit`)
|
||||
}
|
||||
|
||||
function goToCreate(): void {
|
||||
router.push('/weighing-tickets/new')
|
||||
}
|
||||
|
||||
// ── Export XLSX ─────────────────────────────────────────────────────────────
|
||||
// Exporte toute la liste (site courant applique cote back, spec-back § 4.5).
|
||||
const exporting = ref(false)
|
||||
|
||||
async function exportXlsx(): Promise<void> {
|
||||
if (exporting.value) {
|
||||
return
|
||||
}
|
||||
exporting.value = true
|
||||
try {
|
||||
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
|
||||
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
|
||||
// contenu faute d'overload blob sur le client partage (meme pattern M2/M3/M4).
|
||||
const blob = await api.get<Blob>('/weighing_tickets/export.xlsx', {}, {
|
||||
responseType: 'blob',
|
||||
toast: false,
|
||||
} as unknown as Parameters<typeof api.get>[2])
|
||||
|
||||
triggerDownload(blob, 'tickets-pesee.xlsx')
|
||||
}
|
||||
catch {
|
||||
toast.error({
|
||||
title: t('logistique.weighingTickets.toast.error'),
|
||||
message: t('logistique.weighingTickets.toast.exportError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Declenche le telechargement d'un blob via un lien temporaire. */
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
onMounted(loadTickets)
|
||||
</script>
|
||||
@@ -0,0 +1,375 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tête : retour vers la liste + titre. -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('logistique.weighingTickets.form.back')"
|
||||
v-bind="{ ariaLabel: t('logistique.weighingTickets.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('logistique.weighingTickets.form.addTitle') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-[48px] flex flex-col gap-8">
|
||||
<!-- ── Bloc « Poids à vide » (porte la contrepartie, RG-5.03) ──────── -->
|
||||
<WeighingBlock
|
||||
block-id="empty"
|
||||
:title="t('logistique.weighingTickets.form.emptyBlock')"
|
||||
:block="form.empty"
|
||||
:immatriculation="form.immatriculation.value"
|
||||
:plate-free-format="form.plateFreeFormat.value"
|
||||
:errors="emptyBlockErrors"
|
||||
:disabled="emptyLocked"
|
||||
@update:block="(field, value) => updateBlock('empty', field, value)"
|
||||
@update:immatriculation="(v) => form.immatriculation.value = v"
|
||||
@update:plate-free-format="(v) => form.plateFreeFormat.value = v"
|
||||
@request-auto="openAuto('empty')"
|
||||
@request-manual="openManual('empty')"
|
||||
>
|
||||
<!-- Contrepartie : sélecteur + champ conditionnel (RG-5.03). -->
|
||||
<template #counterparty>
|
||||
<MalioSelect
|
||||
:model-value="form.counterpartyType.value"
|
||||
:options="counterpartyOptions"
|
||||
:label="t('logistique.weighingTickets.form.counterparty.type')"
|
||||
:required="true"
|
||||
:disabled="emptyLocked"
|
||||
empty-option-label=""
|
||||
:error="errors.counterpartyType"
|
||||
@update:model-value="onCounterpartyTypeChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="form.counterpartyField.value === 'supplier'"
|
||||
:model-value="form.supplierIri.value"
|
||||
:options="referentials.suppliers.value"
|
||||
:label="t('logistique.weighingTickets.form.counterparty.supplier')"
|
||||
:required="true"
|
||||
:disabled="emptyLocked"
|
||||
empty-option-label=""
|
||||
:error="errors.supplier"
|
||||
@update:model-value="(v: string | number | null) => form.supplierIri.value = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-else-if="form.counterpartyField.value === 'client'"
|
||||
:model-value="form.clientIri.value"
|
||||
:options="referentials.clients.value"
|
||||
:label="t('logistique.weighingTickets.form.counterparty.client')"
|
||||
:required="true"
|
||||
:disabled="emptyLocked"
|
||||
empty-option-label=""
|
||||
:error="errors.client"
|
||||
@update:model-value="(v: string | number | null) => form.clientIri.value = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else-if="form.counterpartyField.value === 'other'"
|
||||
:model-value="form.otherLabel.value"
|
||||
:label="t('logistique.weighingTickets.form.counterparty.other')"
|
||||
:required="true"
|
||||
:disabled="emptyLocked"
|
||||
:error="errors.otherLabel"
|
||||
@update:model-value="(v: string | null) => form.otherLabel.value = v"
|
||||
/>
|
||||
</template>
|
||||
</WeighingBlock>
|
||||
|
||||
<!-- « Enregistrer » du bloc vide : POST initial du ticket (disparaît une
|
||||
fois le ticket créé — RG-5.08). -->
|
||||
<div v-if="form.ticketId.value === null" class="flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('logistique.weighingTickets.form.save')"
|
||||
:disabled="creating"
|
||||
@click="submitCreate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Bloc « Poids à plein » ───────────────────────────────────────-->
|
||||
<WeighingBlock
|
||||
block-id="full"
|
||||
:title="t('logistique.weighingTickets.form.fullBlock')"
|
||||
:block="form.full"
|
||||
:immatriculation="form.immatriculation.value"
|
||||
:plate-free-format="form.plateFreeFormat.value"
|
||||
:errors="fullBlockErrors"
|
||||
@update:block="(field, value) => updateBlock('full', field, value)"
|
||||
@update:immatriculation="(v) => form.immatriculation.value = v"
|
||||
@update:plate-free-format="(v) => form.plateFreeFormat.value = v"
|
||||
@request-auto="openAuto('full')"
|
||||
@request-manual="openManual('full')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- « Valider » (bas d'écran) : PATCH de la pesée à plein puis ouverture du
|
||||
bon de pesée PDF (RG-5.08). Indisponible tant que le ticket n'est pas créé. -->
|
||||
<div class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('logistique.weighingTickets.form.validate')"
|
||||
:disabled="validating || form.ticketId.value === null"
|
||||
@click="submitValidate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
|
||||
<MalioModal v-model="autoModal.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
|
||||
</template>
|
||||
<p>{{ t('logistique.weighingTickets.form.weighbridge.confirmMessage') }}</p>
|
||||
<!-- Erreur de pont indisponible affichée INLINE dans la modal + invite
|
||||
à la pesée manuelle (RG-5.06). -->
|
||||
<p v-if="autoModal.error" class="mt-4 text-m-danger">{{ autoModal.error }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('logistique.weighingTickets.form.weighbridge.cancel')"
|
||||
@click="autoModal.open = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
button-class="flex-1"
|
||||
:label="t('logistique.weighingTickets.form.weighbridge.validate')"
|
||||
:disabled="autoModal.loading"
|
||||
@click="confirmAuto"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
|
||||
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
|
||||
<MalioModal v-model="manualModal.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<MalioInputNumber
|
||||
v-model="manualModal.weight"
|
||||
:label="t('logistique.weighingTickets.form.manual.weight')"
|
||||
:required="true"
|
||||
:min="0"
|
||||
:error="manualModal.errors.weight"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="manualModal.manualNumber"
|
||||
:label="t('logistique.weighingTickets.form.manual.number')"
|
||||
:required="true"
|
||||
:error="manualModal.errors.manualNumber"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('logistique.weighingTickets.form.manual.cancel')"
|
||||
@click="manualModal.open = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
button-class="flex-1"
|
||||
:label="t('logistique.weighingTickets.form.manual.save')"
|
||||
:disabled="manualModal.loading"
|
||||
@click="confirmManual"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
|
||||
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('logistique.weighingTickets.form.addTitle') })
|
||||
|
||||
// Création réservée à `manage` (Admin / Bureau / Usine) — sinon retour à la liste.
|
||||
if (!can('logistique.weighing_tickets.manage')) {
|
||||
await navigateTo('/weighing-tickets')
|
||||
}
|
||||
|
||||
const form = useWeighingTicketForm()
|
||||
const weighbridge = useWeighbridge()
|
||||
const referentials = useWeighingTicketReferentials()
|
||||
const { errors, clearErrors, handleApiError } = useFormErrors()
|
||||
|
||||
// Le bloc vide se verrouille une fois le ticket créé (numéro/site attribués).
|
||||
const emptyLocked = computed(() => form.ticketId.value !== null)
|
||||
|
||||
const creating = ref(false)
|
||||
const validating = ref(false)
|
||||
|
||||
/** Retour vers la liste (flèche d'en-tête). */
|
||||
function goBack(): void {
|
||||
router.push('/weighing-tickets')
|
||||
}
|
||||
|
||||
// ── Contrepartie (RG-5.03) ───────────────────────────────────────────────────
|
||||
// Ordre maquette : Fournisseur / Client / Autre.
|
||||
const counterpartyOptions = computed<RefOption[]>(() => [
|
||||
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
|
||||
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
|
||||
{ value: 'AUTRE', label: t('logistique.weighingTickets.form.counterparty.other') },
|
||||
])
|
||||
|
||||
function onCounterpartyTypeChange(value: string | number | null): void {
|
||||
const type = (value === null || value === '') ? null : (String(value) as 'CLIENT' | 'FOURNISSEUR' | 'AUTRE')
|
||||
form.setCounterpartyType(type)
|
||||
}
|
||||
|
||||
// ── Erreurs par bloc (mapping propertyPath back → champs du composant) ────────
|
||||
const emptyBlockErrors = computed<Record<string, string>>(() => ({
|
||||
date: errors.emptyDate,
|
||||
weight: errors.emptyWeight,
|
||||
dsd: errors.emptyDsd,
|
||||
immatriculation: errors.immatriculation,
|
||||
}))
|
||||
const fullBlockErrors = computed<Record<string, string>>(() => ({
|
||||
date: errors.fullDate,
|
||||
weight: errors.fullWeight,
|
||||
dsd: errors.fullDsd,
|
||||
immatriculation: errors.immatriculation,
|
||||
}))
|
||||
|
||||
/** Mute un champ d'un bloc de pesée (état centralisé dans le form). */
|
||||
function updateBlock(target: 'empty' | 'full', field: keyof WeighingBlockState, value: unknown): void {
|
||||
// Affectation typée via l'index du bloc reactif (date est le seul champ éditable).
|
||||
(form[target] as Record<string, unknown>)[field as string] = value
|
||||
}
|
||||
|
||||
// ── Modal pesée bascule (AUTO) ────────────────────────────────────────────────
|
||||
const autoModal = reactive({
|
||||
open: false,
|
||||
error: '',
|
||||
loading: false,
|
||||
target: 'empty' as 'empty' | 'full',
|
||||
})
|
||||
|
||||
function openAuto(target: 'empty' | 'full'): void {
|
||||
autoModal.target = target
|
||||
autoModal.error = ''
|
||||
autoModal.open = true
|
||||
}
|
||||
|
||||
/** Déclenche la pesée bascule ; erreur (RG-5.06) affichée dans la modal. */
|
||||
async function confirmAuto(): Promise<void> {
|
||||
if (autoModal.loading) return
|
||||
autoModal.loading = true
|
||||
autoModal.error = ''
|
||||
try {
|
||||
const reading = await weighbridge.triggerAuto()
|
||||
form.applyReading(form[autoModal.target], reading)
|
||||
autoModal.open = false
|
||||
}
|
||||
catch (error) {
|
||||
// Pont indisponible : message inline + invite à la pesée manuelle.
|
||||
autoModal.error = weighbridge.extractWeighbridgeError(error)
|
||||
}
|
||||
finally {
|
||||
autoModal.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modal pesée manuelle (MANUAL) ─────────────────────────────────────────────
|
||||
const manualModal = reactive({
|
||||
open: false,
|
||||
loading: false,
|
||||
target: 'empty' as 'empty' | 'full',
|
||||
weight: null as string | number | null,
|
||||
manualNumber: null as string | null,
|
||||
errors: {} as Record<string, string>,
|
||||
})
|
||||
|
||||
function openManual(target: 'empty' | 'full'): void {
|
||||
manualModal.target = target
|
||||
manualModal.weight = null
|
||||
manualModal.manualNumber = null
|
||||
manualModal.errors = {}
|
||||
manualModal.open = true
|
||||
}
|
||||
|
||||
/** Valide la saisie manuelle puis remplit le bloc (DSD calculé serveur, RG-5.04). */
|
||||
async function confirmManual(): Promise<void> {
|
||||
if (manualModal.loading) return
|
||||
manualModal.errors = {}
|
||||
|
||||
const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight)
|
||||
const manualNumber = (manualModal.manualNumber ?? '').trim()
|
||||
if (weight === null || Number.isNaN(weight)) {
|
||||
manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') }
|
||||
}
|
||||
if (manualNumber === '') {
|
||||
manualModal.errors = { ...manualModal.errors, manualNumber: t('logistique.weighingTickets.form.manual.numberRequired') }
|
||||
}
|
||||
if (Object.keys(manualModal.errors).length > 0) return
|
||||
|
||||
manualModal.loading = true
|
||||
try {
|
||||
const reading = await weighbridge.triggerManual(weight as number, manualNumber)
|
||||
form.applyReading(form[manualModal.target], reading)
|
||||
manualModal.open = false
|
||||
}
|
||||
catch (error) {
|
||||
manualModal.errors = { weight: weighbridge.extractWeighbridgeError(error) }
|
||||
}
|
||||
finally {
|
||||
manualModal.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Soumissions ──────────────────────────────────────────────────────────────
|
||||
interface TicketResponse { id: number }
|
||||
|
||||
/** « Enregistrer » du bloc vide : POST /weighing_tickets (création + pesée à vide). */
|
||||
async function submitCreate(): Promise<void> {
|
||||
if (creating.value) return
|
||||
creating.value = true
|
||||
clearErrors()
|
||||
try {
|
||||
const created = await api.post<TicketResponse>('/weighing_tickets', form.buildCreatePayload(), {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
form.ticketId.value = created.id
|
||||
}
|
||||
catch (error) {
|
||||
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||
}
|
||||
finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** « Valider » : PATCH de la pesée à plein puis ouverture du bon de pesée PDF (RG-5.08). */
|
||||
async function submitValidate(): Promise<void> {
|
||||
if (validating.value || form.ticketId.value === null) return
|
||||
validating.value = true
|
||||
clearErrors()
|
||||
try {
|
||||
await api.patch(`/weighing_tickets/${form.ticketId.value}`, form.buildFullPayload(), { toast: false })
|
||||
// Bon de pesée = PDF généré côté back (Twig, ERP-192) — on l'ouvre, on ne
|
||||
// dessine aucun gabarit côté front (RG-5.08).
|
||||
window.open(`/api/weighing_tickets/${form.ticketId.value}/print.pdf`, '_blank')
|
||||
router.push('/weighing-tickets')
|
||||
}
|
||||
catch (error) {
|
||||
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||
}
|
||||
finally {
|
||||
validating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Échec du chargement des référentiels non bloquant : les selects restent vides.
|
||||
referentials.load().catch(() => {})
|
||||
})
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<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"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -12,91 +12,91 @@
|
||||
|
||||
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
|
||||
<MalioSelectCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.siteIris)"
|
||||
:model-value="model.siteIris"
|
||||
:options="siteOptions"
|
||||
:label="t('technique.providers.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Categories de type PRESTATAIRE (>= 1 obligatoire, RG-3.09). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.categoryIris"
|
||||
:options="categoryOptions"
|
||||
:label="t('technique.providers.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
|
||||
<MalioSelectCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.contactIris)"
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('technique.providers.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
v-if="!hideEmpty || isFilled(model.country)"
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('technique.providers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.postalCode)"
|
||||
:model-value="model.postalCode"
|
||||
:label="t('technique.providers.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded"
|
||||
v-if="!degraded && (!hideEmpty || isFilled(model.city))"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('technique.providers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
v-else-if="degraded && (!hideEmpty || isFilled(model.city))"
|
||||
:model-value="model.city"
|
||||
:label="t('technique.providers.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
|
||||
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
||||
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||
<div class="col-span-2">
|
||||
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly"
|
||||
v-if="!readonly && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('technique.providers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('technique.providers.form.address.streetNotFound')"
|
||||
@@ -109,17 +109,20 @@
|
||||
:model-value="model.street"
|
||||
:label="t('technique.providers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1">
|
||||
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('technique.providers.form.address.streetComplement')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
@@ -131,6 +134,8 @@
|
||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
|
||||
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque code postal FR : 5 chiffres.
|
||||
const POSTAL_CODE_MASK = '#####'
|
||||
@@ -138,8 +143,6 @@ const POSTAL_CODE_MASK = '#####'
|
||||
const props = defineProps<{
|
||||
/** Brouillon de l'adresse (v-model). */
|
||||
modelValue: ProviderAddressFormDraft
|
||||
/** Categories autorisees sur une adresse (type PRESTATAIRE). */
|
||||
categoryOptions: RefOption[]
|
||||
/** Sites Starseed disponibles. */
|
||||
siteOptions: RefOption[]
|
||||
/** Contacts deja saisis, rattachables a l'adresse. */
|
||||
@@ -148,6 +151,10 @@ const props = defineProps<{
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -193,11 +200,37 @@ const addressLoading = ref(false)
|
||||
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||
let lastAddressSuggestions: AddressSuggestion[] = []
|
||||
|
||||
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur
|
||||
// les champs texte editables (complement, ville en mode degrade). La voie en
|
||||
// autocomplete (BAN) et la ville en select ne sont pas masquees (le back valide
|
||||
// via Assert\Regex).
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
|
||||
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
|
||||
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
|
||||
* a chaque frappe).
|
||||
*/
|
||||
function onCityChange(value: string | number | null): void {
|
||||
const next = value === null ? null : String(value)
|
||||
if (next === (props.modelValue.city ?? null)) {
|
||||
return
|
||||
}
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
city: next,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
||||
function notifyUnavailable(): void {
|
||||
if (!unavailableNotified) {
|
||||
@@ -208,9 +241,27 @@ function notifyUnavailable(): void {
|
||||
|
||||
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville (RG-3.06). */
|
||||
async function onPostalCodeChange(value: string): Promise<void> {
|
||||
update('postalCode', value)
|
||||
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
|
||||
|
||||
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
|
||||
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
|
||||
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
|
||||
if (digits.length === 5 && digits !== previousDigits) {
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
postalCode: value,
|
||||
city: null,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
else {
|
||||
update('postalCode', value)
|
||||
}
|
||||
|
||||
if (digits.length < 5) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||
non supprimable (1er bloc) ou en lecture seule. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -12,44 +12,56 @@
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||
:model-value="model.lastName"
|
||||
:label="t('technique.providers.form.contact.lastName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||
:model-value="model.firstName"
|
||||
:label="t('technique.providers.form.contact.firstName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div class="col-span-2">
|
||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('technique.providers.form.contact.jobTitle')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
v-if="!hideEmpty || isFilled(model.email)"
|
||||
:model-value="model.email"
|
||||
:label="t('technique.providers.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('technique.providers.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('technique.providers.form.contact.addPhone')"
|
||||
@@ -58,11 +70,12 @@
|
||||
/>
|
||||
<!-- 2e numero : revele a la demande (max 2 telephones par contact). -->
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone"
|
||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('technique.providers.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
@@ -71,6 +84,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
|
||||
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
|
||||
const PHONE_MASK = '## ## ## ## ##'
|
||||
@@ -82,6 +97,10 @@ const props = defineProps<{
|
||||
removable?: boolean
|
||||
/** Bloc en lecture seule (onglet valide). */
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -96,6 +115,10 @@ const { t } = useI18n()
|
||||
// Alias local pour la lisibilite du template.
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
// Filtrage des caracteres parasites : porte par les masks maska sur les champs
|
||||
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
|
||||
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof ProviderContactFormDraft>(field: K, value: ProviderContactFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
|
||||
@@ -46,7 +46,6 @@ function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<str
|
||||
return mount(ProviderAddressBlock, {
|
||||
props: {
|
||||
modelValue: { ...emptyProviderAddress(), ...overrides },
|
||||
categoryOptions: [],
|
||||
siteOptions: [],
|
||||
contactOptions: [],
|
||||
countryOptions: [],
|
||||
@@ -79,17 +78,14 @@ describe('ProviderAddressBlock — version simplifiee M3 (pas de type/bennes/tri
|
||||
})
|
||||
|
||||
describe('ProviderAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
||||
it('affiche les erreurs serveur sur sites et categories (RG-3.05 / RG-3.09)', () => {
|
||||
it('affiche l\'erreur serveur sur sites (RG-3.05)', () => {
|
||||
const wrapper = mountBlock({}, {
|
||||
sites: 'Au moins un site est obligatoire.',
|
||||
categories: 'Au moins une catégorie est obligatoire.',
|
||||
})
|
||||
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
|
||||
const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites')
|
||||
const categoriesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.categories')
|
||||
|
||||
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
|
||||
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
|
||||
})
|
||||
|
||||
it('affiche l\'erreur serveur sur le code postal', () => {
|
||||
|
||||
@@ -330,17 +330,16 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => {
|
||||
return form
|
||||
}
|
||||
|
||||
/** Remplit un bloc adresse valide (site + categorie + scalaires requis). */
|
||||
/** Remplit un bloc adresse valide (site + scalaires requis). */
|
||||
function fillValidAddress(form: ProviderForm, index = 0): void {
|
||||
const a = addressAt(form, index)
|
||||
a.siteIris = [SITE_86]
|
||||
a.categoryIris = [CAT_MAINT]
|
||||
a.postalCode = '86100'
|
||||
a.city = 'Châtellerault'
|
||||
a.street = '1 rue du Test'
|
||||
}
|
||||
|
||||
it('RG-3.05 : « + Nouvelle adresse » desactive tant que site + categorie manquent', () => {
|
||||
it('RG-3.05 : « + Nouvelle adresse » desactive tant que le site manque', () => {
|
||||
const form = createdForm()
|
||||
expect(form.canAddAddress.value).toBe(false)
|
||||
|
||||
@@ -349,8 +348,6 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => {
|
||||
expect(form.addresses.value).toHaveLength(1)
|
||||
|
||||
addressAt(form).siteIris = [SITE_86]
|
||||
expect(form.canAddAddress.value).toBe(false) // categorie manquante
|
||||
addressAt(form).categoryIris = [CAT_MAINT]
|
||||
expect(form.canAddAddress.value).toBe(true)
|
||||
form.addAddress()
|
||||
expect(form.addresses.value).toHaveLength(2)
|
||||
@@ -377,7 +374,7 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => {
|
||||
expect(ok).toBe(true)
|
||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||
expect(url).toBe('/providers/7/addresses')
|
||||
expect(body).toMatchObject({ sites: [SITE_86], categories: [CAT_MAINT], city: 'Châtellerault' })
|
||||
expect(body).toMatchObject({ sites: [SITE_86], city: 'Châtellerault' })
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
expect(addressAt(form).id).toBe(88)
|
||||
expect(form.isValidated('address')).toBe(true)
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('useProvidersRepository', () => {
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||
expect(url).toBe('/providers')
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 25 })
|
||||
expect(opts).toMatchObject({
|
||||
toast: false,
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
|
||||
@@ -84,6 +84,11 @@ export function useProviderForm() {
|
||||
})
|
||||
}
|
||||
|
||||
/** Toast de succès après suppression serveur confirmée d'une sous-ressource. */
|
||||
function notifyRemovalSuccess(): void {
|
||||
toast.success({ title: t('success.title'), message: t('success.deleted') })
|
||||
}
|
||||
|
||||
// ── Etat du prestataire cree ────────────────────────────────────────────
|
||||
const providerId = ref<number | null>(null)
|
||||
const mainLocked = ref(false)
|
||||
@@ -339,6 +344,7 @@ export function useProviderForm() {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyProviderContact,
|
||||
onError: notifyRemovalError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -417,6 +423,7 @@ export function useProviderForm() {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyProviderAddress,
|
||||
onError: notifyRemovalError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -518,6 +525,7 @@ export function useProviderForm() {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyProviderRib,
|
||||
onError: notifyRemovalError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -59,5 +59,6 @@ export interface Provider {
|
||||
* `usePaginatedList`. Aucun reset au logout a gerer.
|
||||
*/
|
||||
export function useProvidersRepository() {
|
||||
return usePaginatedList<Provider>({ url: '/providers' })
|
||||
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
|
||||
return usePaginatedList<Provider>({ url: '/providers', defaultItemsPerPage: 25 })
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('technique.providers.edit.back')"
|
||||
v-bind="{ ariaLabel: t('technique.providers.edit.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -22,8 +23,9 @@
|
||||
<MalioInputText
|
||||
v-model="main.companyName"
|
||||
:label="t('technique.providers.form.main.companyName')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:required="true"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="mainErrors.errors.companyName"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
@@ -31,7 +33,7 @@
|
||||
:options="referentials.categories.value"
|
||||
:label="t('technique.providers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.categories"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
@@ -41,7 +43,7 @@
|
||||
:options="referentials.sites.value"
|
||||
:label="t('technique.providers.form.main.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.sites"
|
||||
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
|
||||
@@ -71,7 +73,7 @@
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
@@ -102,12 +104,11 @@
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:category-options="referentials.categories.value"
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@@ -141,14 +142,15 @@
|
||||
v-model="accounting.siren"
|
||||
:label="t('technique.providers.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.siren"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:label="t('technique.providers.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.accountNumber"
|
||||
/>
|
||||
@@ -156,7 +158,7 @@
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="referentials.tvaModes.value"
|
||||
:label="t('technique.providers.form.accounting.tvaMode')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.tvaMode"
|
||||
@@ -165,7 +167,8 @@
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:label="t('technique.providers.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.nTva"
|
||||
/>
|
||||
@@ -173,7 +176,7 @@
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="referentials.paymentDelays.value"
|
||||
:label="t('technique.providers.form.accounting.paymentDelay')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentDelay"
|
||||
@@ -183,7 +186,7 @@
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="referentials.paymentTypes.value"
|
||||
:label="t('technique.providers.form.accounting.paymentType')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentType"
|
||||
@@ -194,7 +197,7 @@
|
||||
:model-value="accounting.bankIri"
|
||||
:options="referentials.banks.value"
|
||||
:label="t('technique.providers.form.accounting.bank')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.bank"
|
||||
@@ -221,21 +224,23 @@
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('technique.providers.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.label"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:label="t('technique.providers.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.bic"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:label="t('technique.providers.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.iban"
|
||||
/>
|
||||
@@ -313,6 +318,7 @@ import {
|
||||
} from '~/modules/technique/types/providerForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
|
||||
|
||||
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('technique.providers.consultation.back')"
|
||||
v-bind="{ ariaLabel: t('technique.providers.consultation.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -22,7 +23,7 @@
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showArchive"
|
||||
variant="secondary"
|
||||
variant="danger"
|
||||
icon-name="mdi:archive-arrow-down-outline"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.action.archive')"
|
||||
@@ -47,28 +48,32 @@
|
||||
<!-- ── Bloc principal (lecture seule) ─────────────────────────────── -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="isFilled(provider.companyName)"
|
||||
:model-value="provider.companyName"
|
||||
:label="t('technique.providers.form.main.companyName')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
v-if="isFilled(mainCategoryIris)"
|
||||
:model-value="mainCategoryIris"
|
||||
:options="mainCategoryOptions"
|
||||
:label="t('technique.providers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
v-if="isFilled(mainSiteIris)"
|
||||
:model-value="mainSiteIris"
|
||||
:options="mainSiteOptions"
|
||||
:label="t('technique.providers.form.main.sites')"
|
||||
:display-tag="true"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets (navigation libre, tout en lecture seule) ──────────── -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||
<!-- ERP-193 : barre masquee s'il ne reste aucun onglet non vide. -->
|
||||
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||
<!-- Onglet Contacts -->
|
||||
<template #contacts>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
@@ -76,7 +81,8 @@
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
readonly
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -88,31 +94,29 @@
|
||||
v-for="(view, index) in addressViews"
|
||||
:key="index"
|
||||
:model-value="view.draft"
|
||||
:category-options="view.categoryOptions"
|
||||
:site-options="view.siteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptionsFor(view.draft.country)"
|
||||
readonly
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglets placeholder « A venir » (comme les autres modules). -->
|
||||
<template #reports><ComingSoonPlaceholder /></template>
|
||||
<template #exchanges><ComingSoonPlaceholder /></template>
|
||||
<!-- ERP-193 : les onglets « a venir » (Rapports / Echanges) ne sont
|
||||
plus rendus en consultation (masquage des onglets vides). -->
|
||||
|
||||
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" readonly />
|
||||
<MalioInputText :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" readonly />
|
||||
<MalioSelect :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" readonly empty-option-label="" />
|
||||
<MalioInputText :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" readonly />
|
||||
<MalioSelect :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" readonly empty-option-label="" />
|
||||
<MalioSelect :model-value="accounting.paymentTypeIri" :options="paymentTypeOptions" :label="t('technique.providers.form.accounting.paymentType')" readonly empty-option-label="" />
|
||||
<MalioSelect v-if="isBankRequired" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" readonly empty-option-label="" />
|
||||
<MalioInputText v-if="isFilled(accounting.siren)" :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" disabled />
|
||||
<MalioInputText v-if="isFilled(accounting.accountNumber)" :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" disabled />
|
||||
<MalioSelect v-if="isFilled(accounting.tvaModeIri)" :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" disabled empty-option-label="" />
|
||||
<MalioInputText v-if="isFilled(accounting.nTva)" :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" disabled />
|
||||
<MalioSelect v-if="isFilled(accounting.paymentDelayIri)" :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" disabled empty-option-label="" />
|
||||
<MalioSelect v-if="isFilled(accounting.paymentTypeIri)" :model-value="accounting.paymentTypeIri" :options="paymentTypeOptions" :label="t('technique.providers.form.accounting.paymentType')" disabled empty-option-label="" />
|
||||
<MalioSelect v-if="isBankRequired && isFilled(accounting.bankIri)" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" disabled empty-option-label="" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -123,9 +127,9 @@
|
||||
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" readonly />
|
||||
<MalioInputText :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" readonly />
|
||||
<MalioInputText :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" readonly />
|
||||
<MalioInputText v-if="isFilled(rib.label)" :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" disabled />
|
||||
<MalioInputText v-if="isFilled(rib.bic)" :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" disabled />
|
||||
<MalioInputText v-if="isFilled(rib.iban)" :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,7 +162,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useProvider } from '~/modules/technique/composables/useProvider'
|
||||
import {
|
||||
canEditProvider,
|
||||
@@ -170,6 +174,7 @@ import {
|
||||
mapContactToDraft,
|
||||
mapRibToDraft,
|
||||
paymentTypeCodeOf,
|
||||
providerConsultationVisibleTabs,
|
||||
referentialOptionOf,
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
@@ -177,6 +182,7 @@ import {
|
||||
} from '~/modules/technique/utils/forms/providerDetail'
|
||||
import { isBankRequiredForPaymentType, isRibRequiredForPaymentType } from '~/modules/technique/utils/forms/providerAccounting'
|
||||
import { emptyProviderAddress, emptyProviderContact } from '~/modules/technique/types/providerForm'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
@@ -197,7 +203,6 @@ const headerTitle = computed(() => provider.value?.companyName || t('technique.p
|
||||
useHead({ title: t('technique.providers.consultation.title') })
|
||||
|
||||
// ── Onglets (ordre spec : Contacts · Adresse · Rapports · Échanges · Comptabilité) ──
|
||||
const activeTab = ref('contacts')
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
contacts: 'mdi:account-box-plus-outline',
|
||||
address: 'mdi:map-marker-outline',
|
||||
@@ -205,11 +210,27 @@ const TAB_ICONS: Record<string, string> = {
|
||||
exchanges: 'mdi:swap-horizontal',
|
||||
accounting: 'mdi:bank-circle-outline',
|
||||
}
|
||||
const tabs = computed(() => {
|
||||
const keys = ['contacts', 'address', 'reports', 'exchanges']
|
||||
if (canAccountingView.value) keys.push('accounting')
|
||||
return keys.map(key => ({ key, label: t(`technique.providers.tab.${key}`), icon: TAB_ICONS[key] }))
|
||||
})
|
||||
// ERP-193 (retour metier) : on masque les coquilles (Rapports / Echanges) ET
|
||||
// tout onglet de donnees vide. La liste depend donc du payload charge.
|
||||
const visibleTabKeys = computed(() => providerConsultationVisibleTabs(provider.value, {
|
||||
canAccountingView: canAccountingView.value,
|
||||
}))
|
||||
const tabs = computed(() => visibleTabKeys.value.map(
|
||||
key => ({ key, label: t(`technique.providers.tab.${key}`), icon: TAB_ICONS[key] }),
|
||||
))
|
||||
|
||||
// Onglet initial : vide tant que le prestataire n'est pas charge, puis premier
|
||||
// onglet visible. Un watcher recale si l'onglet courant disparait.
|
||||
const activeTab = ref('')
|
||||
watch(visibleTabKeys, (keys) => {
|
||||
if (keys.length === 0) {
|
||||
activeTab.value = ''
|
||||
return
|
||||
}
|
||||
if (!keys.includes(activeTab.value)) {
|
||||
activeTab.value = keys[0]
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// ── Donnees mappees depuis la SEULE reponse detail ─────────────────────────────
|
||||
const mainCategoryIris = computed(() => irisOf(provider.value?.categories))
|
||||
@@ -226,16 +247,15 @@ const contacts = computed(() => {
|
||||
// Contacts rattachables (pour resoudre les libelles des contacts lies aux adresses).
|
||||
const contactOptions = computed(() => contactOptionsOf(provider.value?.contacts))
|
||||
|
||||
// Vue par adresse : brouillon + options propres a l'adresse (sites/categories embarques).
|
||||
// Vue par adresse : brouillon + options propres a l'adresse (sites embarques).
|
||||
const addressViews = computed(() => {
|
||||
const views = (provider.value?.addresses ?? []).map(address => ({
|
||||
draft: mapAddressToDraft(address),
|
||||
siteOptions: siteOptionsOf(address.sites),
|
||||
categoryOptions: categoryOptionsOf(address.categories),
|
||||
}))
|
||||
return views.length > 0
|
||||
? views
|
||||
: [{ draft: emptyProviderAddress(), siteOptions: [], categoryOptions: [] }]
|
||||
: [{ draft: emptyProviderAddress(), siteOptions: [] }]
|
||||
})
|
||||
|
||||
/** Pays : une seule option (la valeur courante), suffisant pour l'affichage readonly. */
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<!-- Categories : libelles (name) separes par une virgule. -->
|
||||
<!-- Categories : libelles (name) separes par une virgule, aligne sur le client (ERP-193). -->
|
||||
<template #cell-categories="{ item }">
|
||||
{{ formatCategories(item) }}
|
||||
</template>
|
||||
@@ -210,7 +210,7 @@ const columns = [
|
||||
{ key: 'lastActivity', label: t('technique.providers.column.lastActivity') },
|
||||
]
|
||||
|
||||
/** Libelles des categories du prestataire, separes par une virgule (name). */
|
||||
/** Libelles (name) des categories du prestataire, separes par une virgule (aligne sur le client, ERP-193). */
|
||||
function formatCategories(item: Record<string, unknown>): string {
|
||||
const categories = (item.categories as Provider['categories']) ?? []
|
||||
return categories.map(c => c.name).join(', ')
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('technique.providers.form.back')"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -21,8 +22,9 @@
|
||||
<MalioInputText
|
||||
v-model="main.companyName"
|
||||
:label="t('technique.providers.form.main.companyName')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:error="mainErrors.errors.companyName"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
@@ -30,7 +32,7 @@
|
||||
:options="referentials.categories.value"
|
||||
:label="t('technique.providers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.categories"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
@@ -40,7 +42,7 @@
|
||||
:options="referentials.sites.value"
|
||||
:label="t('technique.providers.form.main.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.sites"
|
||||
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
|
||||
@@ -72,12 +74,16 @@
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="isValidated('contact')"
|
||||
:disabled="isValidated('contact')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
/>
|
||||
<div v-if="!isValidated('contact')" class="flex justify-center gap-6">
|
||||
<!-- Masque tant que le prestataire n'est pas cree : Contact etant
|
||||
l'onglet actif par defaut, ses actions (Ajouter / Valider) ne
|
||||
doivent pas apparaitre a cote du Valider du formulaire principal
|
||||
(ERP-193). -->
|
||||
<div v-if="!isValidated('contact') && providerId !== null" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
@@ -89,7 +95,7 @@
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.form.submit')"
|
||||
:disabled="tabSubmitting || providerId === null"
|
||||
:disabled="tabSubmitting"
|
||||
@click="onSubmitContacts"
|
||||
/>
|
||||
</div>
|
||||
@@ -102,12 +108,11 @@
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:category-options="referentials.categories.value"
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="isValidated('address')"
|
||||
:disabled="isValidated('address')"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@@ -140,14 +145,15 @@
|
||||
v-model="accounting.siren"
|
||||
:label="t('technique.providers.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.siren"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:label="t('technique.providers.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.accountNumber"
|
||||
/>
|
||||
@@ -155,7 +161,7 @@
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="referentials.tvaModes.value"
|
||||
:label="t('technique.providers.form.accounting.tvaMode')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.tvaMode"
|
||||
@@ -164,7 +170,8 @@
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:label="t('technique.providers.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.nTva"
|
||||
/>
|
||||
@@ -172,7 +179,7 @@
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="referentials.paymentDelays.value"
|
||||
:label="t('technique.providers.form.accounting.paymentDelay')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentDelay"
|
||||
@@ -182,7 +189,7 @@
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="referentials.paymentTypes.value"
|
||||
:label="t('technique.providers.form.accounting.paymentType')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentType"
|
||||
@@ -194,7 +201,7 @@
|
||||
:model-value="accounting.bankIri"
|
||||
:options="referentials.banks.value"
|
||||
:label="t('technique.providers.form.accounting.bank')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.bank"
|
||||
@@ -221,21 +228,23 @@
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('technique.providers.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.label"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:label="t('technique.providers.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.bic"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:label="t('technique.providers.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.iban"
|
||||
/>
|
||||
@@ -297,6 +306,7 @@ import {
|
||||
} from '~/modules/technique/utils/forms/providerAccounting'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isRowRemovable } from '~/shared/utils/collectionRow'
|
||||
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
|
||||
|
||||
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
|
||||
@@ -97,8 +97,6 @@ export interface ProviderAddressFormDraft {
|
||||
city: string | null
|
||||
street: string | null
|
||||
streetComplement: string | null
|
||||
/** IRI des categories rattachees (type PRESTATAIRE, RG-3.09 ; >= 1). */
|
||||
categoryIris: string[]
|
||||
/** IRI des sites rattaches a l'adresse (M2M `provider_address_site`, RG-3.05 ; >= 1). */
|
||||
siteIris: string[]
|
||||
/** IRI des contacts rattaches (= blocs Contact deja persistes de l'onglet Contact). */
|
||||
@@ -114,7 +112,6 @@ export function emptyProviderAddress(): ProviderAddressFormDraft {
|
||||
city: null,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
categoryIris: [],
|
||||
siteIris: [],
|
||||
contactIris: [],
|
||||
}
|
||||
|
||||
@@ -12,21 +12,15 @@ import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
|
||||
*/
|
||||
describe('providerAddress helpers', () => {
|
||||
const SITE = '/api/sites/1'
|
||||
const CAT = '/api/categories/7'
|
||||
|
||||
describe('isProviderAddressValid (RG-3.05 / RG-3.09)', () => {
|
||||
describe('isProviderAddressValid (RG-3.05)', () => {
|
||||
it('false sans site', () => {
|
||||
const address = { ...emptyProviderAddress(), categoryIris: [CAT] }
|
||||
const address = { ...emptyProviderAddress() }
|
||||
expect(isProviderAddressValid(address)).toBe(false)
|
||||
})
|
||||
|
||||
it('false sans categorie', () => {
|
||||
it('true avec au moins un site', () => {
|
||||
const address = { ...emptyProviderAddress(), siteIris: [SITE] }
|
||||
expect(isProviderAddressValid(address)).toBe(false)
|
||||
})
|
||||
|
||||
it('true avec au moins un site ET une categorie', () => {
|
||||
const address = { ...emptyProviderAddress(), siteIris: [SITE], categoryIris: [CAT] }
|
||||
expect(isProviderAddressValid(address)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -39,7 +33,6 @@ describe('providerAddress helpers', () => {
|
||||
city: 'Châtellerault',
|
||||
street: '1 rue du Test',
|
||||
siteIris: [SITE],
|
||||
categoryIris: [CAT],
|
||||
contactIris: ['/api/provider_contacts/9'],
|
||||
})
|
||||
expect(payload).toEqual({
|
||||
@@ -48,7 +41,6 @@ describe('providerAddress helpers', () => {
|
||||
city: 'Châtellerault',
|
||||
street: '1 rue du Test',
|
||||
streetComplement: null,
|
||||
categories: [CAT],
|
||||
sites: [SITE],
|
||||
contacts: ['/api/provider_contacts/9'],
|
||||
})
|
||||
@@ -61,7 +53,6 @@ describe('providerAddress helpers', () => {
|
||||
const payload = buildProviderAddressPayload({
|
||||
...emptyProviderAddress(),
|
||||
siteIris: [SITE],
|
||||
categoryIris: [CAT],
|
||||
})
|
||||
expect(payload).not.toHaveProperty('postalCode')
|
||||
expect(payload).not.toHaveProperty('city')
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
canEditProvider,
|
||||
categoryOptionsOf,
|
||||
contactOptionsOf,
|
||||
hasAccountingData,
|
||||
iriOf,
|
||||
irisOf,
|
||||
mapAccountingDraft,
|
||||
@@ -17,6 +18,7 @@ const {
|
||||
mapContactToDraft,
|
||||
mapRibToDraft,
|
||||
paymentTypeCodeOf,
|
||||
providerConsultationVisibleTabs,
|
||||
referentialOptionOf,
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
@@ -74,7 +76,7 @@ describe('providerDetail helpers', () => {
|
||||
})
|
||||
|
||||
describe('mapAddressToDraft', () => {
|
||||
it('extrait les IRI des sites / categories / contacts embarques', () => {
|
||||
it('extrait les IRI des sites / contacts embarques', () => {
|
||||
const draft = mapAddressToDraft({
|
||||
'@id': '/api/provider_addresses/3',
|
||||
id: 3,
|
||||
@@ -83,11 +85,9 @@ describe('providerDetail helpers', () => {
|
||||
city: 'Châtellerault',
|
||||
street: '1 rue du Test',
|
||||
sites: [{ '@id': '/api/sites/1' }],
|
||||
categories: [{ '@id': '/api/categories/7' }],
|
||||
contacts: [{ '@id': '/api/provider_contacts/5' }, '/api/provider_contacts/6'],
|
||||
})
|
||||
expect(draft.siteIris).toEqual(['/api/sites/1'])
|
||||
expect(draft.categoryIris).toEqual(['/api/categories/7'])
|
||||
expect(draft.contactIris).toEqual(['/api/provider_contacts/5', '/api/provider_contacts/6'])
|
||||
expect(draft.id).toBe(3)
|
||||
})
|
||||
@@ -165,3 +165,48 @@ describe('providerDetail helpers', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAccountingData (prestataire)', () => {
|
||||
it('faux sans champ comptable ni RIB', () => {
|
||||
expect(hasAccountingData({ '@id': '/api/providers/1', id: 1 })).toBe(false)
|
||||
})
|
||||
|
||||
it('vrai avec un champ comptable ou un RIB', () => {
|
||||
expect(hasAccountingData({ '@id': '/api/providers/1', id: 1, siren: '123456789' })).toBe(true)
|
||||
expect(hasAccountingData({
|
||||
'@id': '/api/providers/1', id: 1,
|
||||
ribs: [{ '@id': '/api/provider_ribs/1', id: 1, iban: 'FR76...' }],
|
||||
})).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('providerConsultationVisibleTabs', () => {
|
||||
it('retourne [] tant que le prestataire n\'est pas charge', () => {
|
||||
expect(providerConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
|
||||
})
|
||||
|
||||
it('masque les coquilles (reports/exchanges) et les onglets vides', () => {
|
||||
expect(providerConsultationVisibleTabs(
|
||||
{ '@id': '/api/providers/1', id: 1, companyName: 'ACME' },
|
||||
{ canAccountingView: true },
|
||||
)).toEqual([])
|
||||
})
|
||||
|
||||
it('affiche contacts/address/accounting dans l\'ordre (pas d\'onglet information)', () => {
|
||||
const provider = {
|
||||
'@id': '/api/providers/1', id: 1,
|
||||
contacts: [{ '@id': '/api/provider_contacts/1', id: 1 }],
|
||||
addresses: [{ '@id': '/api/provider_addresses/1', id: 1 }],
|
||||
siren: '123456789',
|
||||
}
|
||||
expect(providerConsultationVisibleTabs(provider, { canAccountingView: true }))
|
||||
.toEqual(['contacts', 'address', 'accounting'])
|
||||
})
|
||||
|
||||
it('masque Comptabilite sans le droit accounting.view', () => {
|
||||
expect(providerConsultationVisibleTabs(
|
||||
{ '@id': '/api/providers/1', id: 1, siren: '123456789' },
|
||||
{ canAccountingView: false },
|
||||
)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,12 +14,12 @@ import type { ProviderAddressFormDraft } from '~/modules/technique/types/provide
|
||||
const REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
|
||||
|
||||
/**
|
||||
* RG-3.05 (+ RG-3.09) : une adresse est « valide » pour autoriser l'ajout d'un
|
||||
* nouveau bloc des qu'elle porte au moins un site ET au moins une categorie. Les
|
||||
* scalaires (CP/ville/rue) restent valides par le back (422 inline).
|
||||
* RG-3.05 : une adresse est « valide » pour autoriser l'ajout d'un nouveau bloc
|
||||
* des qu'elle porte au moins un site. Les scalaires (CP/ville/rue) restent valides
|
||||
* par le back (422 inline).
|
||||
*/
|
||||
export function isProviderAddressValid(address: ProviderAddressFormDraft): boolean {
|
||||
return address.siteIris.length >= 1 && address.categoryIris.length >= 1
|
||||
return address.siteIris.length >= 1
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,7 +34,6 @@ export function buildProviderAddressPayload(address: ProviderAddressFormDraft):
|
||||
city: address.city || null,
|
||||
street: address.street || null,
|
||||
streetComplement: address.streetComplement || null,
|
||||
categories: [...address.categoryIris],
|
||||
sites: [...address.siteIris],
|
||||
contacts: [...address.contactIris],
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ export interface AddressRead extends HydraRef {
|
||||
street?: string | null
|
||||
streetComplement?: string | null
|
||||
sites?: SiteRead[]
|
||||
categories?: CategoryRead[]
|
||||
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
|
||||
contacts?: Array<HydraRef | string>
|
||||
}
|
||||
@@ -146,7 +145,6 @@ export function mapAddressToDraft(address: AddressRead): ProviderAddressFormDraf
|
||||
city: address.city ?? null,
|
||||
street: address.street ?? null,
|
||||
streetComplement: address.streetComplement ?? null,
|
||||
categoryIris: (address.categories ?? []).map(c => c['@id']),
|
||||
siteIris: (address.sites ?? []).map(s => s['@id']),
|
||||
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
|
||||
}
|
||||
@@ -224,6 +222,58 @@ export function paymentTypeCodeOf(relation: Relation): string | null {
|
||||
return (relation.code as string | undefined) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
|
||||
* et non chaine vide apres trim). Sert au predicat « onglet vide » ci-dessous.
|
||||
*/
|
||||
function hasValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return false
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.trim() !== ''
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si l'onglet Comptabilite porte au moins un champ comptable OU un RIB.
|
||||
* (Le gating permission `accounting.view` reste applique en amont par l'appelant.)
|
||||
*/
|
||||
export function hasAccountingData(provider: ProviderDetail): boolean {
|
||||
const draft = mapAccountingDraft(provider)
|
||||
const hasField = Object.values(draft).some(hasValue)
|
||||
const hasRib = (provider.ribs ?? []).length > 0
|
||||
return hasField || hasRib
|
||||
}
|
||||
|
||||
/**
|
||||
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
|
||||
* coquilles non implementees (Rapports / Echanges) ET tout onglet de donnees
|
||||
* vide. Le prestataire n'a pas d'onglet Information (bloc principal au-dessus
|
||||
* des onglets). Ordre : Contacts · Adresse · Comptabilite. Retourne `[]` tant
|
||||
* que le prestataire n'est pas charge.
|
||||
*/
|
||||
export function providerConsultationVisibleTabs(
|
||||
provider: ProviderDetail | null | undefined,
|
||||
options: { canAccountingView: boolean },
|
||||
): string[] {
|
||||
if (!provider) {
|
||||
return []
|
||||
}
|
||||
const visible: string[] = []
|
||||
if ((provider.contacts ?? []).length > 0) {
|
||||
visible.push('contacts')
|
||||
}
|
||||
if ((provider.addresses ?? []).length > 0) {
|
||||
visible.push('address')
|
||||
}
|
||||
if (options.canAccountingView && hasAccountingData(provider)) {
|
||||
visible.push('accounting')
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
/**
|
||||
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet —
|
||||
* `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir
|
||||
|
||||
@@ -3,63 +3,72 @@
|
||||
<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)]">
|
||||
<!-- Pays : prerempli « France » (RG-4.05). -->
|
||||
<MalioSelect
|
||||
v-if="!hideEmpty || isFilled(model.country)"
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('transport.carriers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.country"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). -->
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.postalCode)"
|
||||
:model-value="model.postalCode"
|
||||
:label="t('transport.carriers.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded"
|
||||
v-if="!degraded && (!hideEmpty || isFilled(model.city))"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('transport.carriers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
v-else-if="degraded && (!hideEmpty || isFilled(model.city))"
|
||||
:model-value="model.city"
|
||||
:label="t('transport.carriers.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
|
||||
<!-- Filler : aligne le debut de la ligne suivante sur la grille. -->
|
||||
<div aria-hidden="true" />
|
||||
<!-- Filler : aligne le debut de la ligne suivante sur la grille. Inutile en
|
||||
consultation masquee (la grille se recompose sans les champs vides). -->
|
||||
<div v-if="!hideEmpty" aria-hidden="true" />
|
||||
|
||||
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
||||
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||
<div class="col-span-2">
|
||||
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly"
|
||||
v-if="!readonly && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('transport.carriers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('transport.carriers.form.address.streetNotFound')"
|
||||
@@ -72,16 +81,20 @@
|
||||
:model-value="model.street"
|
||||
:label="t('transport.carriers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.streetComplement)"
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('transport.carriers.form.address.streetComplement')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
@@ -91,6 +104,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
|
||||
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
interface RefOption {
|
||||
value: string
|
||||
@@ -106,6 +121,10 @@ const props = defineProps<{
|
||||
/** Pays disponibles (France par defaut). */
|
||||
countryOptions: RefOption[]
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -150,11 +169,36 @@ const addressLoading = ref(false)
|
||||
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||
let lastAddressSuggestions: AddressSuggestion[] = []
|
||||
|
||||
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur les
|
||||
// champs texte editables (complement, ville en mode degrade). La voie en autocomplete
|
||||
// (BAN) et la ville en select ne sont pas masquees (le back valide via Assert\Regex).
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof CarrierAddressFormDraft>(field: K, value: CarrierAddressFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
|
||||
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
|
||||
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
|
||||
* a chaque frappe).
|
||||
*/
|
||||
function onCityChange(value: string | number | null): void {
|
||||
const next = value === null ? null : String(value)
|
||||
if (next === (props.modelValue.city ?? null)) {
|
||||
return
|
||||
}
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
city: next,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
||||
function notifyUnavailable(): void {
|
||||
if (!unavailableNotified) {
|
||||
@@ -165,9 +209,27 @@ function notifyUnavailable(): void {
|
||||
|
||||
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
|
||||
async function onPostalCodeChange(value: string): Promise<void> {
|
||||
update('postalCode', value)
|
||||
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
|
||||
|
||||
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
|
||||
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
|
||||
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
|
||||
if (digits.length === 5 && digits !== previousDigits) {
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
postalCode: value,
|
||||
city: null,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
else {
|
||||
update('postalCode', value)
|
||||
}
|
||||
|
||||
if (digits.length < 5) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- 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"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -12,44 +12,56 @@
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||
:model-value="model.lastName"
|
||||
:label="t('transport.carriers.form.contact.lastName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||
:model-value="model.firstName"
|
||||
:label="t('transport.carriers.form.contact.firstName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
: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">
|
||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('transport.carriers.form.contact.jobTitle')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
v-if="!hideEmpty || isFilled(model.email)"
|
||||
:model-value="model.email"
|
||||
:label="t('transport.carriers.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
: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
|
||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('transport.carriers.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('transport.carriers.form.contact.addPhone')"
|
||||
@@ -58,11 +70,12 @@
|
||||
/>
|
||||
<!-- 2e numéro : révélé à la demande (max 2 téléphones — RG-4.08). -->
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone"
|
||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('transport.carriers.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
@@ -71,6 +84,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm'
|
||||
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque téléphone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
|
||||
const PHONE_MASK = '## ## ## ## ##'
|
||||
@@ -82,6 +97,10 @@ const props = defineProps<{
|
||||
removable?: boolean
|
||||
/** Bloc en lecture seule (onglet validé). */
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexées par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -96,6 +115,10 @@ const { t } = useI18n()
|
||||
// Alias local pour la lisibilité du template.
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
// Filtrage des caractères parasites : porté par les masks maska sur les champs
|
||||
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
|
||||
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
|
||||
|
||||
/** É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 })
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<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"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -20,7 +20,7 @@
|
||||
:name="`price-direction-${uid}`"
|
||||
value="CLIENT"
|
||||
:label="t('transport.carriers.form.price.directionClient')"
|
||||
:disabled="readonly"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="onDirectionChange"
|
||||
/>
|
||||
@@ -29,7 +29,7 @@
|
||||
:name="`price-direction-${uid}`"
|
||||
value="FOURNISSEUR"
|
||||
:label="t('transport.carriers.form.price.directionSupplier')"
|
||||
:disabled="readonly"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="onDirectionChange"
|
||||
/>
|
||||
@@ -46,6 +46,7 @@
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.client"
|
||||
@update:model-value="onClientChange"
|
||||
/>
|
||||
@@ -56,6 +57,7 @@
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.clientDeliveryAddress"
|
||||
@update:model-value="(v: string | number | null) => update('clientDeliveryAddressIri', v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -66,6 +68,7 @@
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.departureSite"
|
||||
@update:model-value="(v: string | number | null) => update('departureSiteIri', v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -80,6 +83,7 @@
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.supplier"
|
||||
@update:model-value="onSupplierChange"
|
||||
/>
|
||||
@@ -90,6 +94,7 @@
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.supplierSupplyAddress"
|
||||
@update:model-value="(v: string | number | null) => update('supplierSupplyAddressIri', v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -100,6 +105,7 @@
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.deliverySite"
|
||||
@update:model-value="(v: string | number | null) => update('deliverySiteIri', v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -115,7 +121,7 @@
|
||||
:name="`price-container-${uid}`"
|
||||
value="BENNE"
|
||||
:label="t('transport.carriers.containerType.BENNE')"
|
||||
:disabled="readonly"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -124,7 +130,7 @@
|
||||
:name="`price-container-${uid}`"
|
||||
value="FOND_MOUVANT"
|
||||
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
||||
:disabled="readonly"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -140,7 +146,7 @@
|
||||
:name="`price-unit-${uid}`"
|
||||
value="FORFAIT"
|
||||
:label="t('transport.carriers.form.price.pricingForfait')"
|
||||
:disabled="readonly"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -149,7 +155,7 @@
|
||||
:name="`price-unit-${uid}`"
|
||||
value="TONNE"
|
||||
:label="t('transport.carriers.form.price.pricingTonne')"
|
||||
:disabled="readonly"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -162,6 +168,7 @@
|
||||
:label="t('transport.carriers.form.price.price')"
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.price"
|
||||
@update:model-value="(v: string) => update('price', v)"
|
||||
/>
|
||||
@@ -173,6 +180,7 @@
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.priceState"
|
||||
@update:model-value="(v: string | number | null) => update('priceState', v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -200,6 +208,8 @@ const props = defineProps<{
|
||||
siteOptions: SelectOption[]
|
||||
removable?: boolean
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexées par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
|
||||
@@ -37,7 +37,7 @@ vi.stubGlobal('useToast', () => ({
|
||||
}))
|
||||
|
||||
const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm')
|
||||
const { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } = await import('../../utils/forms/carrierContact')
|
||||
const { buildCarrierContactPayload, isCarrierContactBlank } = await import('../../utils/forms/carrierContact')
|
||||
const { buildCarrierPricePayload, isCarrierPriceValid } = await import('../../utils/forms/carrierPrice')
|
||||
const { emptyCarrierContact, emptyCarrierPrice } = await import('../../types/carrierForm')
|
||||
|
||||
@@ -545,6 +545,9 @@ describe('useCarrierForm — onglet Adresse (ERP-167 / ERP-172 : adresse unique)
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
expect(form.address.value.id).toBe(88)
|
||||
expect(form.isValidated('addresses')).toBe(true)
|
||||
// ERP-193 : Contact optionnel → valider Adresses déverrouille jusqu'à Prix
|
||||
// (dernier onglet), sans étape bloquante par Contacts.
|
||||
expect(form.unlockedIndex.value).toBe(CARRIER_TAB_KEYS.length - 1)
|
||||
})
|
||||
|
||||
it('submitAddress : PATCH de l\'adresse existante sur /carrier_addresses/{id}', async () => {
|
||||
@@ -577,7 +580,7 @@ describe('useCarrierForm — onglet Adresse (ERP-167 / ERP-172 : adresse unique)
|
||||
})
|
||||
})
|
||||
|
||||
describe('carrierContact (util) — validité alignée M1/M2/M3 + max 2 téléphones', () => {
|
||||
describe('carrierContact (util) — bloc optionnel (ERP-193) + 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)
|
||||
@@ -586,15 +589,6 @@ describe('carrierContact (util) — validité alignée M1/M2/M3 + max 2 téléph
|
||||
expect(isCarrierContactBlank({ ...emptyCarrierContact(), phoneSecondary: '0605040302', hasSecondaryPhone: true })).toBe(true)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it('buildCarrierContactPayload : phones = 1 numéro sans secondaire', () => {
|
||||
const body = buildCarrierContactPayload({ ...emptyCarrierContact(), phonePrimary: '0102030405' })
|
||||
expect(body.phones).toEqual(['0102030405'])
|
||||
@@ -635,23 +629,18 @@ describe('useCarrierForm — onglet Contacts (ERP-168)', () => {
|
||||
return form
|
||||
}
|
||||
|
||||
it('« + Nouveau contact » désactivé tant que le bloc n\'est pas nommé (prénom OU nom, aligné M1/M2/M3)', () => {
|
||||
it('ERP-193 : « + Nouveau contact » désactivé tant que le bloc est VIDE (plus de règle prénom/nom)', () => {
|
||||
const form = createdForm()
|
||||
expect(form.canAddContact.value).toBe(false)
|
||||
|
||||
// addContact est un no-op tant que le bloc n'est pas nommé.
|
||||
// addContact est un no-op tant que le bloc est totalement vide.
|
||||
form.addContact()
|
||||
expect(form.contacts.value).toHaveLength(1)
|
||||
|
||||
// Fonction seule ne suffit PAS à ajouter un nouveau bloc (≠ RG-4.08 large).
|
||||
// ERP-193 : un seul champ rempli (ici la fonction, sans prénom ni nom) suffit
|
||||
// désormais à débloquer l'ajout — la règle « prénom OU nom » est retirée.
|
||||
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)
|
||||
@@ -686,21 +675,15 @@ describe('useCarrierForm — onglet Contacts (ERP-168)', () => {
|
||||
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.' }] },
|
||||
},
|
||||
})
|
||||
it('ERP-193 : onglet Contact vide → aucun POST, onglet finalisé (bloc optionnel)', async () => {
|
||||
const form = createdForm()
|
||||
|
||||
// Bloc vide → rien n'est soumis, l'onglet se finalise et déverrouille Prix.
|
||||
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)
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.isValidated('contacts')).toBe(true)
|
||||
})
|
||||
|
||||
it('removeContact : DELETE /carrier_contacts/{id} puis retrait du bloc', async () => {
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('useCarriersRepository', () => {
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||
expect(url).toBe('/carriers')
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 25 })
|
||||
expect(opts).toMatchObject({
|
||||
toast: false,
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type CarrierPriceFormDraft,
|
||||
} from '~/modules/transport/types/carrierForm'
|
||||
import { buildCarrierAddressPayload } from '~/modules/transport/utils/forms/carrierAddress'
|
||||
import { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } from '~/modules/transport/utils/forms/carrierContact'
|
||||
import { buildCarrierContactPayload, isCarrierContactBlank } from '~/modules/transport/utils/forms/carrierContact'
|
||||
import { buildCarrierPricePayload, isCarrierPriceValid } from '~/modules/transport/utils/forms/carrierPrice'
|
||||
import {
|
||||
mapAddressToDraft,
|
||||
@@ -416,6 +416,11 @@ export function useCarrierForm() {
|
||||
})
|
||||
}
|
||||
|
||||
/** Toast de succès après suppression serveur confirmée d'une sous-ressource. */
|
||||
function notifyRemovalSuccess(): void {
|
||||
toast.success({ title: t('success.title'), message: t('success.deleted') })
|
||||
}
|
||||
|
||||
/**
|
||||
* Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX :
|
||||
* on n'arrête pas au premier bloc en échec (décision ERP-101). Réinitialise la
|
||||
@@ -488,6 +493,10 @@ export function useCarrierForm() {
|
||||
await api.patch(`/carrier_addresses/${address.value.id}`, body, { toast: false })
|
||||
}
|
||||
completeTab('addresses')
|
||||
// ERP-193 : l'onglet Contact est OPTIONNEL — il ne doit pas verrouiller
|
||||
// l'accès à Prix. Dès les Adresses validées, on déverrouille jusqu'à Prix
|
||||
// (Contacts reste accessible mais n'est plus une étape bloquante).
|
||||
unlockedIndex.value = tabKeys.value.length - 1
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
@@ -511,12 +520,13 @@ export function useCarrierForm() {
|
||||
// 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).
|
||||
// « + Nouveau contact » désactivé tant que le DERNIER bloc est VIDE. ERP-193 :
|
||||
// l'onglet Contact n'est plus obligatoire — on ne réclame plus prénom OU nom,
|
||||
// un seul champ rempli (fonction / téléphone / email) suffit pour empiler un
|
||||
// bloc suivant (et évite d'accumuler des blocs totalement vides).
|
||||
const canAddContact = computed(() => {
|
||||
const last = contacts.value[contacts.value.length - 1]
|
||||
return last !== undefined && isCarrierContactNamed(last)
|
||||
return last !== undefined && !isCarrierContactBlank(last)
|
||||
})
|
||||
|
||||
function addContact(): void {
|
||||
@@ -535,16 +545,18 @@ export function useCarrierForm() {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyCarrierContact,
|
||||
onError: notifyRemovalError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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é.
|
||||
* (groupe carrier:write:contacts). Max 2 téléphones re-validé back → 422 par
|
||||
* ligne. ERP-193 : l'onglet Contact est OPTIONNEL — les amorces vides neuves
|
||||
* sont systématiquement ignorées (pas de contact vide créé) et un onglet sans
|
||||
* aucun bloc rempli est simplement finalisé, déverrouillant l'onglet Prix.
|
||||
* Retourne true si l'onglet a été validé.
|
||||
*/
|
||||
async function submitContacts(onError: (error: unknown) => void): Promise<boolean> {
|
||||
if (carrierId.value === null || tabSubmitting.value) {
|
||||
@@ -552,7 +564,6 @@ export function useCarrierForm() {
|
||||
}
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
const hasSubmittable = contacts.value.some(c => c.id !== null || !isCarrierContactBlank(c))
|
||||
const hasError = await submitRows(
|
||||
contacts.value,
|
||||
contactErrors,
|
||||
@@ -571,9 +582,9 @@ export function useCarrierForm() {
|
||||
}
|
||||
},
|
||||
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),
|
||||
// Amorce vide neuve toujours ignorée (bloc Contact optionnel, ERP-193) :
|
||||
// un onglet sans aucun bloc rempli se finalise sans rien créer.
|
||||
contact => contact.id === null && isCarrierContactBlank(contact),
|
||||
)
|
||||
if (hasError) {
|
||||
return false
|
||||
@@ -648,6 +659,7 @@ export function useCarrierForm() {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyCarrierPrice,
|
||||
onError: notifyRemovalError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -66,5 +66,6 @@ export interface CarrierFilters {
|
||||
* `usePaginatedList`. Aucun reset au logout a gerer.
|
||||
*/
|
||||
export function useCarriersRepository() {
|
||||
return usePaginatedList<Carrier, CarrierFilters>({ url: '/carriers' })
|
||||
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
|
||||
return usePaginatedList<Carrier, CarrierFilters>({ url: '/carriers', defaultItemsPerPage: 25 })
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('transport.carriers.edit.back')"
|
||||
v-bind="{ ariaLabel: t('transport.carriers.edit.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -20,18 +21,24 @@
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="main.name"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
: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"
|
||||
/>
|
||||
<!-- Cas LIOT : le champ immatriculations occupe les colonnes restantes
|
||||
de la ligne (3 en xl, 2 sinon). Wrapper pour le col-span car
|
||||
MalioInputText (inheritAttrs:false) renvoie `class` sur l'input. -->
|
||||
<div v-if="isLiot" class="col-span-2 xl:col-span-3">
|
||||
<MalioInputText
|
||||
v-model="main.liotPlates"
|
||||
:mask="LIOT_PLATES_MASK"
|
||||
:label="t('transport.carriers.form.main.liotPlates')"
|
||||
:hint="t('transport.carriers.form.main.liotPlatesHint')"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.liotPlates"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="!isLiot">
|
||||
<MalioSelect
|
||||
:model-value="main.certificationType"
|
||||
@@ -39,7 +46,7 @@
|
||||
:label="t('transport.carriers.form.main.certificationType')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="certificationReadonly"
|
||||
:disabled="certificationReadonly"
|
||||
:error="mainErrors.errors.certificationType"
|
||||
@update:model-value="(v: string | number | null) => setCertification(v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -49,7 +56,7 @@
|
||||
:label="t('transport.carriers.form.main.discharge')"
|
||||
accept="application/pdf,image/*"
|
||||
:required="true"
|
||||
:readonly="dischargeUploading"
|
||||
:disabled="dischargeUploading"
|
||||
:clearable="true"
|
||||
:error="mainErrors.errors.dischargeDocument"
|
||||
@update:model-value="(v: string) => dischargeFileName = v"
|
||||
@@ -213,7 +220,8 @@ import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTa
|
||||
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'
|
||||
import { clampPercent, LIOT_PLATES_MASK, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
|
||||
import { FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
|
||||
|
||||
interface SelectOption {
|
||||
value: string
|
||||
@@ -284,9 +292,13 @@ const TAB_ICONS: Record<string, string> = {
|
||||
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 => ({
|
||||
const TAB_KEYS = ['qualimat', 'addresses', 'contacts', 'prices']
|
||||
// ERP-193 : on honore l'onglet demande via `?tab=` (navigation depuis la
|
||||
// consultation) pour retomber sur le meme onglet ; defaut « addresses ».
|
||||
const requestedTab = typeof route.query.tab === 'string' ? route.query.tab : ''
|
||||
const activeTab = ref(TAB_KEYS.includes(requestedTab) ? requestedTab : 'addresses')
|
||||
const tabs = computed(() => TAB_KEYS.map(key => ({
|
||||
key,
|
||||
label: t(`transport.carriers.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
@@ -364,7 +376,8 @@ function onIndexationInput(value: string): void {
|
||||
}
|
||||
|
||||
function goBack(): void {
|
||||
router.push(`/carriers/${carrierId}`)
|
||||
// ERP-193 : on transmet l'onglet courant pour retomber dessus en consultation.
|
||||
router.push({ path: `/carriers/${carrierId}`, query: { tab: activeTab.value } })
|
||||
}
|
||||
|
||||
/** PATCH du formulaire principal (pas de re-POST). */
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('transport.carriers.consultation.back')"
|
||||
v-bind="{ ariaLabel: t('transport.carriers.consultation.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -22,7 +23,7 @@
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showArchive"
|
||||
variant="secondary"
|
||||
variant="danger"
|
||||
icon-name="mdi:archive-arrow-down-outline"
|
||||
icon-position="left"
|
||||
:label="t('transport.carriers.action.archive')"
|
||||
@@ -45,56 +46,58 @@
|
||||
<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 />
|
||||
<MalioInputText v-if="isFilled(main.name)" :model-value="main.name" :label="t('transport.carriers.form.main.name')" disabled />
|
||||
|
||||
<!-- Cas LIOT : seul le champ immatriculations. -->
|
||||
<MalioInputText
|
||||
v-if="isLiot"
|
||||
:model-value="main.liotPlates"
|
||||
:label="t('transport.carriers.form.main.liotPlates')"
|
||||
readonly
|
||||
/>
|
||||
<!-- Cas LIOT : le champ immatriculations occupe les colonnes restantes
|
||||
de la ligne (3 en xl, 2 sinon), comme à l'ajout / la modification. -->
|
||||
<div v-if="isLiot && isFilled(main.liotPlates)" class="col-span-2 xl:col-span-3">
|
||||
<MalioInputText
|
||||
:model-value="main.liotPlates"
|
||||
:label="t('transport.carriers.form.main.liotPlates')"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Cas standard : certification + décharge (col 3 réservée) + affrètement (col 4). -->
|
||||
<template v-if="!isLiot">
|
||||
<MalioInputText
|
||||
v-if="isFilled(certificationLabel)"
|
||||
:model-value="certificationLabel"
|
||||
:label="t('transport.carriers.form.main.certificationType')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
|
||||
<!-- Colonne 3 réservée à la décharge (si AUTRE), sinon vide (xl). -->
|
||||
<!-- Décharge (si AUTRE) : affichee uniquement si un document est attache. -->
|
||||
<MalioInputText
|
||||
v-if="main.certificationType === 'AUTRE'"
|
||||
v-if="main.certificationType === 'AUTRE' && isFilled(dischargeLabel)"
|
||||
:model-value="dischargeLabel"
|
||||
:label="t('transport.carriers.form.main.discharge')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<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">
|
||||
<!-- Affréter : masquee si non cochee (ERP-193). -->
|
||||
<div v-if="isFilled(main.isChartered)" class="flex h-12 items-center">
|
||||
<MalioCheckbox
|
||||
id="carrier-view-chartered"
|
||||
:label="t('transport.carriers.form.main.isChartered')"
|
||||
:model-value="main.isChartered"
|
||||
readonly
|
||||
disabled
|
||||
: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 />
|
||||
<MalioInputText v-if="isFilled(indexationDisplay)" :model-value="indexationDisplay" :label="t('transport.carriers.form.main.indexationRate')" disabled />
|
||||
<!-- Contenant : radios désactivés (lecture seule), aligné sur l'ajout / la modif. -->
|
||||
<div>
|
||||
<div v-if="isFilled(main.containerType)">
|
||||
<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
|
||||
disabled
|
||||
group-class="mt-0"
|
||||
/>
|
||||
<MalioRadioButton
|
||||
@@ -102,25 +105,27 @@
|
||||
name="carrier-view-container"
|
||||
value="FOND_MOUVANT"
|
||||
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
||||
readonly
|
||||
disabled
|
||||
group-class="mt-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MalioInputText :model-value="main.volumeM3" :label="t('transport.carriers.form.main.volumeM3')" readonly />
|
||||
<MalioInputText v-if="isFilled(main.volumeM3)" :model-value="main.volumeM3" :label="t('transport.carriers.form.main.volumeM3')" disabled />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets (Adresses · Contacts · Prix) — ouvre sur Adresses ──── -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||
<!-- ERP-193 : barre masquee s'il ne reste aucun onglet non vide. -->
|
||||
<MalioTabList v-if="visibleTabKeys.length" 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
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -131,7 +136,8 @@
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
readonly
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -144,14 +150,15 @@
|
||||
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. -->
|
||||
<!-- Répartition (table-fixed) : « Transport » étroit (libellé
|
||||
court Benne / Fond mouvant) ; Fournisseurs/Clients et
|
||||
Adresse livraisons larges ; Forfait / Tonne / Indexation
|
||||
/ État réduits. -->
|
||||
<colgroup>
|
||||
<col class="w-[170px]" />
|
||||
<col class="w-[120px]" />
|
||||
<col class="w-[20%]" />
|
||||
<col class="w-[11%]" />
|
||||
<col class="w-[24%]" />
|
||||
<col class="w-[11%]" />
|
||||
<col class="w-[9%]" />
|
||||
<col class="w-[9%]" />
|
||||
<col class="w-[9%]" />
|
||||
@@ -162,8 +169,8 @@
|
||||
<!-- 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.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.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>
|
||||
@@ -171,28 +178,21 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="(group, gi) in priceGroups" :key="group.label">
|
||||
<template v-for="(group, gi) in priceGroups" :key="gi">
|
||||
<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>
|
||||
<!-- Contenant par ligne (plus de cellule fusionnée) ; séparateur
|
||||
à droite, comme l'ancienne colonne de groupe. -->
|
||||
<td class="border-r border-black px-3 py-4 text-center align-middle text-[14px] font-medium" :class="dataBorder(gi, i)">{{ row.transport }}</td>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.party }}</td>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.delivery }}</td>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.apro }}</td>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.forfait }}</td>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.tonne }}</td>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.indexation }}</td>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.state }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="!hasPrices">
|
||||
@@ -241,12 +241,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } 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,
|
||||
carrierConsultationVisibleTabs,
|
||||
labelOfRelation,
|
||||
mapAddressToDraft,
|
||||
mapContactToDraft,
|
||||
@@ -257,6 +258,7 @@ import {
|
||||
type Relation,
|
||||
} from '~/modules/transport/utils/forms/carrierMappers'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
interface SelectOption {
|
||||
value: string
|
||||
@@ -300,18 +302,36 @@ const dischargeLabel = computed(() => {
|
||||
})
|
||||
|
||||
// ── Onglets : Adresses · Contacts · Prix (ouvre sur Adresses, pas de Qualimat) ──
|
||||
const activeTab = ref('addresses')
|
||||
// ERP-193 (retour metier) : on masque tout onglet de donnees vide.
|
||||
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 => ({
|
||||
const visibleTabKeys = computed(() => carrierConsultationVisibleTabs(carrier.value))
|
||||
const tabs = computed(() => visibleTabKeys.value.map(key => ({
|
||||
key,
|
||||
label: t(`transport.carriers.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
})))
|
||||
|
||||
// Onglet initial : vide tant que le transporteur n'est pas charge, puis premier
|
||||
// onglet visible. Un watcher recale si l'onglet courant disparait. ERP-193 : on
|
||||
// honore l'onglet demande via `?tab=` (navigation depuis l'edition) s'il est
|
||||
// visible, pour retomber sur le meme onglet en passant edition <-> consultation.
|
||||
const activeTab = ref('')
|
||||
let requestedTab = typeof route.query.tab === 'string' ? route.query.tab : ''
|
||||
watch(visibleTabKeys, (keys) => {
|
||||
if (keys.length === 0) {
|
||||
activeTab.value = ''
|
||||
return
|
||||
}
|
||||
if (!keys.includes(activeTab.value)) {
|
||||
activeTab.value = requestedTab && keys.includes(requestedTab) ? requestedTab : keys[0]
|
||||
requestedTab = ''
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 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)
|
||||
@@ -326,10 +346,17 @@ 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
|
||||
// ── Tableau Prix consultation (regroupé par ADRESSE DE LIVRAISON) ─────────────
|
||||
// Rang d'affichage des contenants au sein d'une même adresse (Fond mouvant puis Benne).
|
||||
const CONTAINER_RANK: Record<string, number> = { FOND_MOUVANT: 0, BENNE: 1 }
|
||||
|
||||
interface PriceRowView {
|
||||
/** Contenant (libellé affiché : Fond mouvant / Benne). */
|
||||
transport: string
|
||||
/** Contenant brut (FOND_MOUVANT / BENNE) — tri interne du groupe. */
|
||||
transportType: string
|
||||
/** Fournisseur ou client lié au prix (raison sociale). */
|
||||
party: string
|
||||
apro: string
|
||||
delivery: string
|
||||
forfait: string
|
||||
@@ -338,9 +365,8 @@ interface PriceRowView {
|
||||
state: string
|
||||
}
|
||||
|
||||
/** Groupe de prix d'un même contenant (Fond Mouvant / Benne) — cellule fusionnée. */
|
||||
/** Groupe de prix d'une même adresse de livraison (lignes consécutives). */
|
||||
interface PriceGroupView {
|
||||
label: string
|
||||
rows: PriceRowView[]
|
||||
}
|
||||
|
||||
@@ -367,13 +393,19 @@ function siteCode(relation: Relation): string {
|
||||
|
||||
/**
|
||||
* Construit une ligne d'affichage depuis un prix embarqué (maquette Prix) :
|
||||
* - « Transport » = le contenant (Fond mouvant / Benne) ;
|
||||
* - « Fournisseurs / Clients » = le fournisseur OU le client lié (raison sociale) ;
|
||||
* - « 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'
|
||||
const containerType = price.containerType ?? ''
|
||||
return {
|
||||
transportType: containerType,
|
||||
transport: containerType ? t(`transport.carriers.containerType.${containerType}`) : '',
|
||||
party: labelOfRelation(isClient ? price.client : price.supplier),
|
||||
apro: isClient ? siteCode(price.departureSite) : siteCode(price.deliverySite),
|
||||
delivery: isClient ? labelOfRelation(price.clientDeliveryAddress) : labelOfRelation(price.supplierSupplyAddress),
|
||||
forfait: price.pricingUnit === 'FORFAIT' ? formatAmount(price.price) : '',
|
||||
@@ -392,39 +424,48 @@ function stateSuffix(state: string): string {
|
||||
return map[state] ?? ''
|
||||
}
|
||||
|
||||
// Prix regroupés par contenant (Fond Mouvant puis Benne) — une cellule fusionnée
|
||||
// par groupe (rowspan) à gauche, conformément à la maquette.
|
||||
// Prix regroupés par ADRESSE DE LIVRAISON : les lignes d'une même adresse sont
|
||||
// consécutives (triées par contenant Fond mouvant → Benne), les groupes triés
|
||||
// alphabétiquement par adresse. Un séparateur épais sépare deux adresses.
|
||||
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),
|
||||
const rows = (carrier.value?.prices ?? []).map(toPriceRow)
|
||||
const byDelivery = new Map<string, PriceRowView[]>()
|
||||
for (const row of rows) {
|
||||
const list = byDelivery.get(row.delivery)
|
||||
if (list) {
|
||||
list.push(row)
|
||||
} else {
|
||||
byDelivery.set(row.delivery, [row])
|
||||
}
|
||||
}
|
||||
return [...byDelivery.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b, 'fr'))
|
||||
.map(([, groupRows]) => ({
|
||||
rows: groupRows
|
||||
.slice()
|
||||
.sort((x, y) => (CONTAINER_RANK[x.transportType] ?? 99) - (CONTAINER_RANK[y.transportType] ?? 99)),
|
||||
}))
|
||||
.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) ;
|
||||
* - ligne interne d'un groupe d'adresse (même adresse de livraison) → fine grise ;
|
||||
* - dernière ligne d'un groupe NON final → épaisse noire (sépare deux adresses) ;
|
||||
* - 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 {
|
||||
function dataBorder(gi: number, i: number): string {
|
||||
const group = priceGroups.value[gi]
|
||||
const isLastRow = i === group.rows.length - 1
|
||||
const isLastGroup = gi === priceGroups.value.length - 1
|
||||
// Couleur de bordure SIDE-SPECIFIC (border-b-*) : un `border-{color}` global
|
||||
// ecraserait la couleur du bord droit noir de la colonne Transport.
|
||||
if (!isLastRow) {
|
||||
return 'border-b border-m-muted/30'
|
||||
return 'border-b border-b-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'
|
||||
return isLastGroup ? '' : 'border-b-2 border-b-black'
|
||||
}
|
||||
|
||||
// ── Export XLSX des prix ─────────────────────────────────────────────────────
|
||||
@@ -465,7 +506,8 @@ function goBack(): void {
|
||||
}
|
||||
|
||||
function goEdit(): void {
|
||||
router.push(`/carriers/${carrierId}/edit`)
|
||||
// ERP-193 : on transmet l'onglet courant pour retomber dessus en edition.
|
||||
router.push({ path: `/carriers/${carrierId}/edit`, query: activeTab.value ? { tab: activeTab.value } : {} })
|
||||
}
|
||||
|
||||
const confirmArchive = reactive({ open: false, title: '', message: '', confirmLabel: '' })
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('transport.carriers.form.back')"
|
||||
v-bind="{ ariaLabel: t('transport.carriers.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -20,22 +21,28 @@
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="main.name"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:label="t('transport.carriers.form.main.name')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:error="mainErrors.errors.name"
|
||||
/>
|
||||
|
||||
<!-- Cas LIOT : seul le champ immatriculations est pertinent. -->
|
||||
<MalioInputText
|
||||
v-if="isLiot"
|
||||
v-model="main.liotPlates"
|
||||
:label="t('transport.carriers.form.main.liotPlates')"
|
||||
:hint="t('transport.carriers.form.main.liotPlatesHint')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:error="mainErrors.errors.liotPlates"
|
||||
/>
|
||||
<!-- Cas LIOT : seul le champ immatriculations est pertinent. Il occupe
|
||||
les colonnes restantes de la ligne (3 en xl, 2 sinon) — le wrapper
|
||||
porte le col-span car MalioInputText (inheritAttrs:false) renvoie
|
||||
`class` sur l'input interne, pas sur la cellule de grille. -->
|
||||
<div v-if="isLiot" class="col-span-2 xl:col-span-3">
|
||||
<MalioInputText
|
||||
v-model="main.liotPlates"
|
||||
:mask="LIOT_PLATES_MASK"
|
||||
:label="t('transport.carriers.form.main.liotPlates')"
|
||||
:hint="t('transport.carriers.form.main.liotPlatesHint')"
|
||||
:required="true"
|
||||
:disabled="mainLocked"
|
||||
:error="mainErrors.errors.liotPlates"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Cas standard : certification + affretement + champs conditionnels. -->
|
||||
<template v-if="!isLiot">
|
||||
@@ -45,7 +52,7 @@
|
||||
:label="t('transport.carriers.form.main.certificationType')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="certificationReadonly"
|
||||
:disabled="certificationReadonly"
|
||||
:error="mainErrors.errors.certificationType"
|
||||
@update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)"
|
||||
/>
|
||||
@@ -61,7 +68,7 @@
|
||||
:label="t('transport.carriers.form.main.discharge')"
|
||||
accept="application/pdf,image/*"
|
||||
:required="true"
|
||||
:readonly="mainLocked || dischargeUploading"
|
||||
:disabled="mainLocked || dischargeUploading"
|
||||
:clearable="true"
|
||||
:error="mainErrors.errors.dischargeDocument"
|
||||
@update:model-value="(v: string) => dischargeFileName = v"
|
||||
@@ -78,7 +85,7 @@
|
||||
id="carrier-is-chartered"
|
||||
:label="t('transport.carriers.form.main.isChartered')"
|
||||
:model-value="main.isChartered"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:reserve-message-space="false"
|
||||
@update:model-value="(val: boolean) => main.isChartered = val"
|
||||
/>
|
||||
@@ -98,7 +105,7 @@
|
||||
icon-name="mdi:percent"
|
||||
icon-position="right"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:error="mainErrors.errors.indexationRate"
|
||||
@update:model-value="onIndexationInput"
|
||||
/>
|
||||
@@ -134,7 +141,7 @@
|
||||
:model-value="main.volumeM3"
|
||||
:label="t('transport.carriers.form.main.volumeM3')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:error="mainErrors.errors.volumeM3"
|
||||
@update:model-value="(v: string) => main.volumeM3 = sanitizeDecimal(v)"
|
||||
/>
|
||||
@@ -174,7 +181,7 @@
|
||||
<CarrierAddressBlock
|
||||
:model-value="address"
|
||||
:country-options="countryOptions"
|
||||
:readonly="isQualimat || isValidated('addresses')"
|
||||
:disabled="isQualimat || isValidated('addresses')"
|
||||
:errors="addressErrors"
|
||||
@update:model-value="(v) => address = v"
|
||||
@degraded="onAddressDegraded"
|
||||
@@ -192,8 +199,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Contacts (ERP-168) : un bloc par contact (RG-4.08 ≥ 1 champ,
|
||||
max 2 téléphones). Erreurs 422 par ligne. -->
|
||||
<!-- Onglet Contacts (ERP-168) : un bloc par contact (bloc optionnel,
|
||||
ERP-193 ; max 2 téléphones). Erreurs 422 par ligne. -->
|
||||
<template #contacts>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<CarrierContactBlock
|
||||
@@ -201,7 +208,7 @@
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="isValidated('contacts')"
|
||||
:disabled="isValidated('contacts')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
@@ -237,7 +244,7 @@
|
||||
:supplier-options="supplierOptions"
|
||||
:site-options="siteOptions"
|
||||
:removable="!isValidated('prices')"
|
||||
:readonly="isValidated('prices')"
|
||||
:disabled="isValidated('prices')"
|
||||
:errors="priceErrors[index]"
|
||||
@update:model-value="(v) => prices[index] = v"
|
||||
@remove="askRemovePrice(index)"
|
||||
@@ -307,7 +314,8 @@ import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.
|
||||
import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTab.vue'
|
||||
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
||||
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
|
||||
import { clampPercent, LIOT_PLATES_MASK, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
|
||||
import { FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
|
||||
|
||||
interface SelectOption {
|
||||
value: string
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
canEditCarrier,
|
||||
carrierConsultationVisibleTabs,
|
||||
hasAddressData,
|
||||
iriOf,
|
||||
labelOfRelation,
|
||||
mapAddressToDraft,
|
||||
@@ -25,6 +27,10 @@ describe('carrierMappers', () => {
|
||||
expect(iriOf(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('labelOfRelation : companyName (client/fournisseur) prioritaire sur name/adresse', () => {
|
||||
expect(labelOfRelation({ '@id': '/api/suppliers/8', companyName: 'AAAAAAA', name: 'X' })).toBe('AAAAAAA')
|
||||
})
|
||||
|
||||
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')
|
||||
@@ -118,3 +124,47 @@ describe('carrierMappers', () => {
|
||||
expect(showRestoreAction(noArchive, true)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAddressData', () => {
|
||||
it('faux pour une adresse absente ou entièrement vide', () => {
|
||||
expect(hasAddressData(null)).toBe(false)
|
||||
expect(hasAddressData(undefined)).toBe(false)
|
||||
expect(hasAddressData({ '@id': '/api/carrier_addresses/1', id: 1 })).toBe(false)
|
||||
})
|
||||
|
||||
it('vrai dès qu\'un champ adresse est rempli', () => {
|
||||
expect(hasAddressData({ '@id': '/api/carrier_addresses/1', id: 1, city: 'Poitiers' })).toBe(true)
|
||||
expect(hasAddressData({ '@id': '/api/carrier_addresses/1', id: 1, country: 'France' })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('carrierConsultationVisibleTabs', () => {
|
||||
it('retourne [] tant que le transporteur n\'est pas chargé', () => {
|
||||
expect(carrierConsultationVisibleTabs(null)).toEqual([])
|
||||
expect(carrierConsultationVisibleTabs(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('masque les onglets vides (transporteur minimal)', () => {
|
||||
expect(carrierConsultationVisibleTabs({ '@id': '/api/carriers/1', id: 1, name: 'LIOT' })).toEqual([])
|
||||
})
|
||||
|
||||
it('affiche addresses/contacts/prices dans l\'ordre quand renseignés', () => {
|
||||
const carrier: CarrierDetail = {
|
||||
'@id': '/api/carriers/1', id: 1,
|
||||
address: { '@id': '/api/carrier_addresses/1', id: 1, city: 'Poitiers' },
|
||||
contacts: [{ '@id': '/api/carrier_contacts/1', id: 1 }],
|
||||
prices: [{ '@id': '/api/carrier_prices/1', id: 1 }],
|
||||
}
|
||||
expect(carrierConsultationVisibleTabs(carrier)).toEqual(['addresses', 'contacts', 'prices'])
|
||||
})
|
||||
|
||||
it('ne garde que les onglets non vides (contacts seulement)', () => {
|
||||
const carrier: CarrierDetail = {
|
||||
'@id': '/api/carriers/1', id: 1,
|
||||
address: { '@id': '/api/carrier_addresses/1', id: 1 },
|
||||
contacts: [{ '@id': '/api/carrier_contacts/1', id: 1 }],
|
||||
prices: [],
|
||||
}
|
||||
expect(carrierConsultationVisibleTabs(carrier)).toEqual(['contacts'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { clampPercent, sanitizeDecimal } from '../numberInput'
|
||||
import { Mask } from 'maska'
|
||||
import { clampPercent, LIOT_PLATES_MASK, sanitizeDecimal } from '../numberInput'
|
||||
|
||||
describe('numberInput — saisie volume / indexation (ERP-170)', () => {
|
||||
it('sanitizeDecimal : ne garde que chiffres + un seul point', () => {
|
||||
@@ -19,4 +20,14 @@ describe('numberInput — saisie volume / indexation (ERP-170)', () => {
|
||||
expect(clampPercent('12,5')).toBe('12,5') // ≤ 100 → inchangé
|
||||
expect(clampPercent('')).toBe('')
|
||||
})
|
||||
|
||||
it('LIOT_PLATES_MASK : garde lettres/chiffres/tiret/point-virgule, bloque espaces et reste', () => {
|
||||
// Reproduit ce que fait maska au runtime (MaskInput) : preProcess puis masked.
|
||||
const masked = (v: string) => new Mask(LIOT_PLATES_MASK).masked(LIOT_PLATES_MASK.preProcess!(v))
|
||||
expect(masked('AB-123-CD;EF-456-GH')).toBe('AB-123-CD;EF-456-GH')
|
||||
expect(masked('ab-123-cd ; ef-456-gh')).toBe('ab-123-cd;ef-456-gh') // espaces retirés
|
||||
expect(masked('AB 123 CD')).toBe('AB123CD') // espaces retirés
|
||||
expect(masked('AB.123/CD#42&²²')).toBe('AB123CD42') // . / # & ² retirés
|
||||
expect(masked('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* 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.
|
||||
* Helpers purs de l'onglet Contact transporteur (M4 Transport, ERP-168). ERP-193
|
||||
* (retour métier) : l'onglet Contact n'est plus obligatoire — la règle « prénom OU
|
||||
* nom » est retirée. Le gating « + Nouveau contact » repose désormais sur « le
|
||||
* dernier bloc n'est pas vide » (et non plus « nommé »). 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'
|
||||
@@ -30,15 +30,6 @@ export function isCarrierContactBlank(contact: CarrierContactFormDraft): boolean
|
||||
].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
|
||||
|
||||
@@ -97,13 +97,18 @@ export function iriOf(relation: Relation): string | 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.
|
||||
* Libellé d'affichage d'une relation embarquée : `companyName` (client/fournisseur)
|
||||
* à défaut `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 companyName = relation.companyName as string | undefined
|
||||
if (companyName) {
|
||||
return companyName
|
||||
}
|
||||
const name = relation.name as string | undefined
|
||||
if (name) {
|
||||
return name
|
||||
@@ -175,6 +180,62 @@ export function mapPriceToDraft(price: CarrierPriceRead): CarrierPriceFormDraft
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si une valeur scalaire porte une donnée affichable (non null/undefined,
|
||||
* et non chaîne vide après trim). Sert aux prédicats « onglet vide » ci-dessous.
|
||||
*/
|
||||
function hasValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return false
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.trim() !== ''
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si l'adresse unique (OneToOne, ERP-172) porte au moins un champ rempli.
|
||||
* Un objet adresse vide (toutes clés nulles) est considéré comme « pas d'adresse ».
|
||||
*/
|
||||
export function hasAddressData(address: CarrierAddressRead | null | undefined): boolean {
|
||||
if (!address) {
|
||||
return false
|
||||
}
|
||||
return [
|
||||
address.postalCode,
|
||||
address.city,
|
||||
address.street,
|
||||
address.streetComplement,
|
||||
address.country,
|
||||
].some(hasValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Onglets visibles en consultation (ERP-193, retour métier) : on masque tout
|
||||
* onglet de données vide. Le transporteur n'a pas de coquille « à venir ».
|
||||
* Ordre : Adresses · Contacts · Prix. Retourne `[]` tant que le transporteur
|
||||
* n'est pas chargé.
|
||||
*/
|
||||
export function carrierConsultationVisibleTabs(
|
||||
carrier: CarrierDetail | null | undefined,
|
||||
): string[] {
|
||||
if (!carrier) {
|
||||
return []
|
||||
}
|
||||
const visible: string[] = []
|
||||
if (hasAddressData(carrier.address)) {
|
||||
visible.push('addresses')
|
||||
}
|
||||
if ((carrier.contacts ?? []).length > 0) {
|
||||
visible.push('contacts')
|
||||
}
|
||||
if ((carrier.prices ?? []).length > 0) {
|
||||
visible.push('prices')
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
/** Bouton « Modifier » : visible avec la permission `manage` (Admin / Bureau). */
|
||||
export function canEditCarrier(can: (code: string) => boolean): boolean {
|
||||
return can('transport.carriers.manage')
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Helpers de saisie numérique du formulaire principal transporteur (ERP-170).
|
||||
* Champs texte restreints (volume m³ décimal, indexation plafonnée). Purs / testables.
|
||||
* Helpers de saisie du formulaire principal transporteur (ERP-170).
|
||||
* Champs texte restreints (volume m³ décimal, indexation plafonnée, immatriculations
|
||||
* LIOT via mask maska). Purs / testables.
|
||||
*/
|
||||
import type { MaskInputOptions } from 'maska'
|
||||
|
||||
/**
|
||||
* Restreint une saisie à un nombre décimal : chiffres + UN seul point (RG volume m³,
|
||||
@@ -26,3 +28,20 @@ export function clampPercent(value: string): string {
|
||||
const n = Number(String(value ?? '').replace(',', '.').replace(/\s/g, ''))
|
||||
return (!Number.isNaN(n) && n > 100) ? '100' : value
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask maska des immatriculations LIOT : n'autorise que lettres, chiffres, tiret et
|
||||
* point-virgule (séparateur de plaques), longueur libre. Filtrage NATIF (maska gère
|
||||
* le focus et le curseur, contrairement à un nettoyage manuel). Espaces et tout autre
|
||||
* caractère sont bloqués à la frappe / au collage. La normalisation finale (majuscules
|
||||
* + « ; » espacé) reste au back (RG-4.13).
|
||||
*
|
||||
* `preProcess` retire d'abord tout caractère interdit (espaces, &, ², …) OÙ QU'IL
|
||||
* SOIT (le masque positionnel seul s'arrêterait au 1er caractère invalide) ; le
|
||||
* token `P` en `multiple: true` laisse ensuite passer le reste (longueur libre).
|
||||
*/
|
||||
export const LIOT_PLATES_MASK: MaskInputOptions = {
|
||||
mask: 'P',
|
||||
tokens: { P: { pattern: /[A-Za-z0-9;-]/, multiple: true } },
|
||||
preProcess: (value: string) => value.replace(/[^A-Za-z0-9;-]/g, ''),
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
||||
"name": "starseed-frontend",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.7.12",
|
||||
"@malio/layer-ui": "^1.7.15",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -1866,9 +1866,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.7.12",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.12/layer-ui-1.7.12.tgz",
|
||||
"integrity": "sha512-ezQLqi19K2ogI3XwSMsUyluU9x5C4W0tu1muxFbL3foKjibRYRg/FdvySivEhEsalAAt1E88V6Sv/06xPqyYTw==",
|
||||
"version": "1.7.15",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.15/layer-ui-1.7.15.tgz",
|
||||
"integrity": "sha512-CgEC0l2pkR6rlzpi1zZqswHs+/yGTSd861tdT678/wSKtQPQ6JxUIf63ugFDItyvyLW+nbcNWuHTFC2Bimp1EQ==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.7.12",
|
||||
"@malio/layer-ui": "^1.7.15",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -22,11 +22,12 @@ describe('removeCollectionRow', () => {
|
||||
const errors: Record<string, string>[] = [{}, {}]
|
||||
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||
const onError = vi.fn()
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const removed = await removeCollectionRow({
|
||||
rows, errors, index: 0,
|
||||
endpoint: '/client_contacts',
|
||||
deleteRow, makeEmpty, onError,
|
||||
deleteRow, makeEmpty, onError, onSuccess,
|
||||
})
|
||||
|
||||
expect(deleteRow).toHaveBeenCalledOnce()
|
||||
@@ -35,6 +36,8 @@ describe('removeCollectionRow', () => {
|
||||
expect(rows).toEqual([{ id: 11, label: 'B' }])
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
// Toast de succes uniquement sur suppression serveur confirmee.
|
||||
expect(onSuccess).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('ne fait AUCUN appel reseau pour un bloc jamais persiste (id null) — retrait local', async () => {
|
||||
@@ -42,16 +45,19 @@ describe('removeCollectionRow', () => {
|
||||
const errors: Record<string, string>[] = [{}, {}]
|
||||
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||
const onError = vi.fn()
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const removed = await removeCollectionRow({
|
||||
rows, errors, index: 1,
|
||||
endpoint: '/client_contacts',
|
||||
deleteRow, makeEmpty, onError,
|
||||
deleteRow, makeEmpty, onError, onSuccess,
|
||||
})
|
||||
|
||||
expect(deleteRow).not.toHaveBeenCalled()
|
||||
expect(removed).toBe(true)
|
||||
expect(rows).toEqual([{ id: 10, label: 'A' }])
|
||||
// Retrait d'un simple brouillon local : pas de toast « supprime ».
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('conserve le bloc et remonte l\'erreur si le DELETE serveur echoue (ex. 409 dernier RIB LCR)', async () => {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isFilled } from '../consultationDisplay'
|
||||
|
||||
describe('isFilled (masquage des champs vides en consultation, ERP-193)', () => {
|
||||
it('considere VIDE : null / undefined / chaine vide ou espaces / tableau vide / false', () => {
|
||||
expect(isFilled(null)).toBe(false)
|
||||
expect(isFilled(undefined)).toBe(false)
|
||||
expect(isFilled('')).toBe(false)
|
||||
expect(isFilled(' ')).toBe(false)
|
||||
expect(isFilled([])).toBe(false)
|
||||
// Case a cocher non cochee => masquee.
|
||||
expect(isFilled(false)).toBe(false)
|
||||
})
|
||||
|
||||
it('considere REMPLI : chaine non vide / tableau non vide / nombre (y compris 0) / true / objet', () => {
|
||||
expect(isFilled('Dupont')).toBe(true)
|
||||
expect(isFilled(['/api/sites/1'])).toBe(true)
|
||||
expect(isFilled(0)).toBe(true)
|
||||
expect(isFilled(42)).toBe(true)
|
||||
expect(isFilled(true)).toBe(true)
|
||||
expect(isFilled({ '@id': '/api/x/1' })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { todayIso } from '../date'
|
||||
|
||||
describe('todayIso', () => {
|
||||
it('formate la date locale en YYYY-MM-DD (zero-pad mois/jour)', () => {
|
||||
// 7 mars 2026 (heure locale) -> '2026-03-07'.
|
||||
expect(todayIso(new Date(2026, 2, 7, 10, 30))).toBe('2026-03-07')
|
||||
})
|
||||
|
||||
it('utilise les composantes LOCALES, pas UTC (pas de decalage de minuit)', () => {
|
||||
// 18 juin 2026 23:30 heure locale : la date locale reste le 18 meme si
|
||||
// toISOString() (UTC) basculerait au 19 selon le fuseau.
|
||||
expect(todayIso(new Date(2026, 5, 18, 23, 30))).toBe('2026-06-18')
|
||||
})
|
||||
|
||||
it('gere le dernier jour de l\'annee', () => {
|
||||
expect(todayIso(new Date(2026, 11, 31, 12, 0))).toBe('2026-12-31')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Mask, type MaskInputOptions } from 'maska'
|
||||
import {
|
||||
ADDRESS_MASK,
|
||||
CODE_ALNUM_MASK,
|
||||
FREE_TEXT_MASK,
|
||||
PERSON_NAME_MASK,
|
||||
} from '../textSanitize'
|
||||
|
||||
/** Reproduit le traitement maska au runtime (MaskInput) : preProcess puis masked. */
|
||||
function apply(mask: MaskInputOptions, value: string): string {
|
||||
const pre = mask.preProcess ? mask.preProcess(value) : value
|
||||
return new Mask(mask).masked(pre)
|
||||
}
|
||||
|
||||
describe('PERSON_NAME_MASK', () => {
|
||||
it('garde lettres accentuees, espace, apostrophe, tiret, point', () => {
|
||||
expect(apply(PERSON_NAME_MASK, 'Jean-Pierre')).toBe('Jean-Pierre')
|
||||
expect(apply(PERSON_NAME_MASK, 'O’Brien')).toBe('O’Brien')
|
||||
expect(apply(PERSON_NAME_MASK, "D'Angelo")).toBe("D'Angelo")
|
||||
expect(apply(PERSON_NAME_MASK, 'Saint-Étienne J.')).toBe('Saint-Étienne J.')
|
||||
})
|
||||
|
||||
it('retire chiffres et caracteres parasites (ou qu\'ils soient)', () => {
|
||||
expect(apply(PERSON_NAME_MASK, 'Dupont²³')).toBe('Dupont')
|
||||
expect(apply(PERSON_NAME_MASK, 'Jean§&#~|')).toBe('Jean')
|
||||
expect(apply(PERSON_NAME_MASK, 'Ma§rie123')).toBe('Marie') // parasite AU MILIEU
|
||||
})
|
||||
})
|
||||
|
||||
describe('FREE_TEXT_MASK', () => {
|
||||
it('garde &, /, parentheses, degre, chiffres', () => {
|
||||
expect(apply(FREE_TEXT_MASK, 'Dupont & Fils')).toBe('Dupont & Fils')
|
||||
expect(apply(FREE_TEXT_MASK, 'Resp. Achats/Ventes')).toBe('Resp. Achats/Ventes')
|
||||
expect(apply(FREE_TEXT_MASK, 'SARL Léon (Pôle n°2)')).toBe('SARL Léon (Pôle n°2)')
|
||||
})
|
||||
|
||||
it('retire les parasites ²³§~#|', () => {
|
||||
expect(apply(FREE_TEXT_MASK, 'ACME²³§')).toBe('ACME')
|
||||
expect(apply(FREE_TEXT_MASK, 'Te~#|st<>{}')).toBe('Test')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ADDRESS_MASK', () => {
|
||||
it('garde chiffres, virgule, point, apostrophe, slash, degre, tiret', () => {
|
||||
expect(apply(ADDRESS_MASK, '12 bis, rue de l’Église')).toBe('12 bis, rue de l’Église')
|
||||
expect(apply(ADDRESS_MASK, 'Bât. n°3 - Zone A/B')).toBe('Bât. n°3 - Zone A/B')
|
||||
})
|
||||
|
||||
it('retire les parasites', () => {
|
||||
expect(apply(ADDRESS_MASK, '5 rue X²³§&')).toBe('5 rue X')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CODE_ALNUM_MASK', () => {
|
||||
it('force la majuscule et ne garde que A-Z 0-9', () => {
|
||||
expect(apply(CODE_ALNUM_MASK, '411dupont')).toBe('411DUPONT')
|
||||
expect(apply(CODE_ALNUM_MASK, 'FR 12 345')).toBe('FR12345')
|
||||
expect(apply(CODE_ALNUM_MASK, '4-11.000§')).toBe('411000')
|
||||
})
|
||||
|
||||
it('chaine vide reste vide', () => {
|
||||
expect(apply(CODE_ALNUM_MASK, '')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,12 @@ export interface RemoveCollectionRowOptions<T extends DeletableRow> {
|
||||
makeEmpty: () => T
|
||||
/** Remontee d'erreur 409/422 mappee proprement (message back, pas de toast fourre-tout). */
|
||||
onError: (error: unknown) => void
|
||||
/**
|
||||
* Callback de succes (toast) appele UNIQUEMENT apres une suppression serveur
|
||||
* confirmee d'un bloc persiste (`id` non null). Pas appele sur le simple retrait
|
||||
* d'un brouillon local non enregistre (aucune suppression reelle).
|
||||
*/
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,8 +61,9 @@ export interface RemoveCollectionRowOptions<T extends DeletableRow> {
|
||||
export async function removeCollectionRow<T extends DeletableRow>(
|
||||
options: RemoveCollectionRowOptions<T>,
|
||||
): Promise<boolean> {
|
||||
const { rows, errors, index, endpoint, deleteRow, makeEmpty, onError } = options
|
||||
const { rows, errors, index, endpoint, deleteRow, makeEmpty, onError, onSuccess } = options
|
||||
const removed = rows[index]
|
||||
let serverDeleted = false
|
||||
|
||||
// Bloc existant : suppression serveur d'abord, retrait local seulement si OK.
|
||||
if (removed?.id != null) {
|
||||
@@ -67,6 +74,7 @@ export async function removeCollectionRow<T extends DeletableRow>(
|
||||
onError(error)
|
||||
return false
|
||||
}
|
||||
serverDeleted = true
|
||||
}
|
||||
|
||||
rows.splice(index, 1)
|
||||
@@ -75,5 +83,9 @@ export async function removeCollectionRow<T extends DeletableRow>(
|
||||
if (rows.length === 0) {
|
||||
rows.push(makeEmpty())
|
||||
}
|
||||
// Toast de succes uniquement quand le serveur a confirme une vraie suppression.
|
||||
if (serverDeleted) {
|
||||
onSuccess?.()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Helpers d'affichage en CONSULTATION (lecture seule).
|
||||
*
|
||||
* Decision metier (ERP-193) : en consultation, on masque les champs non remplis
|
||||
* (et les cases a cocher non cochees) pour ne montrer que l'information saisie.
|
||||
* Mutualise entre modules (fournisseur, prestataire, client, transporteur) : la
|
||||
* meme regle « vide » s'applique partout.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Indique si une valeur est « remplie » (donc a afficher en consultation).
|
||||
*
|
||||
* Sont consideres VIDES (donc masques) :
|
||||
* - null / undefined
|
||||
* - chaine vide ou composee uniquement d'espaces
|
||||
* - tableau vide (multiselect / cases a cocher sans selection)
|
||||
* - booleen `false` (case a cocher non cochee)
|
||||
*
|
||||
* Restent AFFICHES : tout nombre (y compris 0, qui est une valeur saisie), les
|
||||
* objets non nuls, et toute chaine non vide.
|
||||
*/
|
||||
export function isFilled(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return false
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.trim() !== ''
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Helpers de date purs / testables (partages inter-modules).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Date du jour au format ISO `YYYY-MM-DD` en heure LOCALE.
|
||||
*
|
||||
* On NE passe PAS par `toISOString()` (UTC) : pres de minuit, le decalage de
|
||||
* fuseau (FR = UTC+1/+2) renverrait la veille ou le lendemain. On lit donc les
|
||||
* composantes locales. Parametre `now` injectable pour les tests.
|
||||
*/
|
||||
export function todayIso(now: Date = new Date()): string {
|
||||
const year = now.getFullYear()
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Masks de saisie texte (retour metier ERP-193) : filtrage NATIF (maska) des
|
||||
* caracteres parasites (« ²³§~#| … ») dans les champs texte libres. maska gere le
|
||||
* focus et le curseur (contrairement a un nettoyage manuel sur @update qui laissait
|
||||
* le caractere affiche jusqu'a la frappe suivante).
|
||||
*
|
||||
* Miroir FRONT des patterns back `App\Shared\Domain\Validation\TextInputPattern`
|
||||
* (allow-list par famille de champ). Le back reste l'autorite (Assert\Regex →
|
||||
* 422 inline via useFormErrors) ; ces masks ne font que le confort de saisie.
|
||||
*
|
||||
* IMPORTANT : garder les classes de caracteres STRICTEMENT alignees sur le back.
|
||||
*
|
||||
* L'EMAIL n'a PAS de mask (decision ERP-101 : un email n'a pas de structure fixe,
|
||||
* on valide le FORMAT via Assert\Email + erreur inline, jamais via un masque).
|
||||
*/
|
||||
import type { MaskInputOptions } from 'maska'
|
||||
|
||||
/**
|
||||
* Construit un mask maska « jeu de caracteres autorise, longueur libre » :
|
||||
* - `preProcess` retire d'abord TOUT caractere hors charset, OU QU'IL SOIT (un
|
||||
* masque positionnel seul s'arreterait au 1er caractere invalide car le token
|
||||
* `multiple` est glouton) ;
|
||||
* - le token `P` (`multiple`) laisse ensuite passer le reste, sans limite de longueur.
|
||||
*
|
||||
* @param pattern classe des caracteres AUTORISES (1 caractere, sans flag global)
|
||||
* @param strip negation de `pattern`, flag global (retire les interdits)
|
||||
* @param upper force la majuscule (codes : n° compte / TVA / IBAN / BIC)
|
||||
*/
|
||||
function charsetMask(pattern: RegExp, strip: RegExp, upper = false): MaskInputOptions {
|
||||
return {
|
||||
mask: 'P',
|
||||
tokens: { P: { pattern, multiple: true } },
|
||||
preProcess: (v: string) => (upper ? v.toUpperCase() : v).replace(strip, ''),
|
||||
}
|
||||
}
|
||||
|
||||
/** Noms de personnes (Nom, Prenom, Dirigeant) : lettres (accents), espace, apostrophe, tiret, point. */
|
||||
export const PERSON_NAME_MASK = charsetMask(/[\p{L}\p{M} '’.-]/u, /[^\p{L}\p{M} '’.-]/gu)
|
||||
|
||||
/** Texte societe / libre (Raison sociale, Concurrents, Fonction) : + chiffres, virgule, &, /, parentheses, degre. */
|
||||
export const FREE_TEXT_MASK = charsetMask(/[\p{L}\p{M}0-9 '’.,&/()°-]/u, /[^\p{L}\p{M}0-9 '’.,&/()°-]/gu)
|
||||
|
||||
/** Adresse (voie, complement, ville) : lettres, chiffres, espace, apostrophe, point, virgule, slash, degre, tiret. */
|
||||
export const ADDRESS_MASK = charsetMask(/[\p{L}\p{M}0-9 '’.,/°-]/u, /[^\p{L}\p{M}0-9 '’.,/°-]/gu)
|
||||
|
||||
/** Codes alphanumeriques majuscules (N° de compte, N° de TVA, IBAN, BIC) : A-Z et 0-9, majuscule forcee. */
|
||||
export const CODE_ALNUM_MASK = charsetMask(/[A-Z0-9]/, /[^A-Z0-9]/g, true)
|
||||
@@ -75,7 +75,7 @@ COPY infra/prod/maintenance.html /var/www/html/public/maintenance.html
|
||||
RUN echo "APP_ENV=prod" > /var/www/html/.env
|
||||
|
||||
# Permissions
|
||||
RUN mkdir -p /var/www/html/var /var/www/html/config/jwt \
|
||||
RUN mkdir -p /var/www/html/var /var/www/html/var/log /var/www/html/config/jwt \
|
||||
&& chown -R www-data:www-data /var/www/html/var
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* M5 — Tickets de pesee (ERP-182) : creation du schema BDD du module Logistique.
|
||||
*
|
||||
* Objets crees :
|
||||
* - site.code : code court du site (86/17/82), prefixe de numerotation des
|
||||
* tickets (RG-5.02). Backfill depuis les 2 premiers chiffres du code postal
|
||||
* + index unique uq_site_code (§ 2.5). NULLABLE a ce ticket (l'entite Site ne
|
||||
* mappe pas encore `code`) ; le mapping ORM + peuplement + SET NOT NULL sont
|
||||
* portes par le ticket entite (WeighingTicket).
|
||||
* - weighing_ticket_counter : sequence du numero de ticket par site (RG-5.02).
|
||||
* - weighbridge_dsd_counter : compteur DSD du pont bascule par site (RG-5.04).
|
||||
* - weighing_ticket : table principale (contrepartie Client/Fournisseur/Autre,
|
||||
* immatriculation partagee, pesees a vide + a plein en colonnes plates,
|
||||
* poids net derive, soft-delete prepare + Timestampable/Blamable).
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
|
||||
* la table porte des FK cross-module (user, client, supplier, site). Le tri par
|
||||
* timestamp au sein du namespace racine garantit l'ordre apres la creation de
|
||||
* ces tables sur base vide ; un namespace modulaire casserait `make db-reset`.
|
||||
*
|
||||
* Convention IDs (spec § 2.2) : `INT GENERATED BY DEFAULT AS IDENTITY`,
|
||||
* horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le TimestampableBlamableTrait
|
||||
* mappe `datetime_immutable`).
|
||||
*
|
||||
* Chaque colonne porte son `COMMENT ON COLUMN` (regle ABSOLUE n°12).
|
||||
*
|
||||
* NB schema:update (test-db-setup) :
|
||||
* - weighing_ticket_counter / weighbridge_dsd_counter ne sont JAMAIS mappees en
|
||||
* ORM (DBAL brut sous verrou FOR UPDATE, § 2.5 / § 2.7) -> exclues du
|
||||
* `schema_filter` (config/packages/doctrine.yaml) pour que schema:update ne
|
||||
* les drope pas. Leurs descriptions sont aussi catalogue-es dans
|
||||
* ColumnCommentsCatalog (rejeu par `app:apply-column-comments`).
|
||||
* - weighing_ticket et la colonne site.code seront mappes en ORM au ticket
|
||||
* suivant (entite WeighingTicket + propriete Site::code) ; d'ici la,
|
||||
* schema:update les drope sur la base de TEST uniquement (sans impact : aucun
|
||||
* test ne les reference encore, et dev/prod ne lancent jamais schema:update).
|
||||
*/
|
||||
final class Version20260617150000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-182 (M5) : site.code + compteurs (numero ticket / DSD) + table weighing_ticket (tickets de pesee).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSiteCode();
|
||||
$this->createWeighingTicketCounter();
|
||||
$this->createWeighbridgeDsdCounter();
|
||||
$this->createWeighingTicket();
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Ordre inverse : table principale puis compteurs, enfin la colonne site.code.
|
||||
$this->addSql('DROP TABLE IF EXISTS weighing_ticket');
|
||||
$this->addSql('DROP TABLE IF EXISTS weighbridge_dsd_counter');
|
||||
$this->addSql('DROP TABLE IF EXISTS weighing_ticket_counter');
|
||||
$this->addSql('DROP INDEX IF EXISTS uq_site_code');
|
||||
$this->addSql('ALTER TABLE site DROP COLUMN IF EXISTS code');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// site.code — prefixe de numerotation des tickets (§ 2.5)
|
||||
// =================================================================
|
||||
|
||||
private function addSiteCode(): void
|
||||
{
|
||||
// Colonne NULLABLE a ce ticket : l'entite Site ne mappe pas encore `code`,
|
||||
// donc tout persist ORM (fixtures, tests) l'omettrait -> un NOT NULL casserait
|
||||
// `make db-reset`. Le mapping ORM Site::code, son peuplement (86/17/82) et le
|
||||
// passage `SET NOT NULL` sont portes par le ticket suivant (entite WeighingTicket
|
||||
// + Site::code), via une 2e migration. L'index unique est pose des maintenant
|
||||
// (Postgres tolere plusieurs NULL) : il garantit l'unicite des qu'ils seront peuples.
|
||||
$this->addSql('ALTER TABLE site ADD COLUMN code VARCHAR(8) DEFAULT NULL');
|
||||
// Backfill : 2 premiers chiffres du code postal (departement) par defaut,
|
||||
// editable ensuite cote admin Sites. No-op sur base fraiche (aucun site encore).
|
||||
$this->addSql('UPDATE site SET code = LEFT(postal_code, 2) WHERE code IS NULL');
|
||||
$this->addSql('CREATE UNIQUE INDEX uq_site_code ON site (code)');
|
||||
|
||||
$this->comment('site', 'code', 'Code court du site (ex. 86/17/82) — prefixe de numerotation des tickets de pesee (RG-5.02). Unique (uq_site_code). Backfill = 2 premiers chiffres du CP. NOT NULL pose au ticket entite.');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Compteur du numero de ticket (sequence par site) — RG-5.02
|
||||
// =================================================================
|
||||
|
||||
private function createWeighingTicketCounter(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE weighing_ticket_counter (
|
||||
site_id INT NOT NULL,
|
||||
last_value INT DEFAULT 0 NOT NULL,
|
||||
PRIMARY KEY (site_id),
|
||||
CONSTRAINT fk_wt_counter_site
|
||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->comment('weighing_ticket_counter', '_table', 'Sequence du numero de ticket de pesee par site (RG-5.02, M5 Logistique) — incrementee en DBAL brut sous verrou FOR UPDATE, hors ORM.');
|
||||
$this->comment('weighing_ticket_counter', 'site_id', 'Site proprietaire de la sequence (1 ligne par site). PK + FK -> site.id, ON DELETE CASCADE.');
|
||||
$this->comment('weighing_ticket_counter', 'last_value', 'Dernier numero de ticket attribue pour le site. Increment verrouille FOR UPDATE (RG-5.02).');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Compteur DSD (pesee du pont, par site) — RG-5.04
|
||||
// =================================================================
|
||||
|
||||
private function createWeighbridgeDsdCounter(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE weighbridge_dsd_counter (
|
||||
site_id INT NOT NULL,
|
||||
last_value INT DEFAULT 0 NOT NULL,
|
||||
PRIMARY KEY (site_id),
|
||||
CONSTRAINT fk_dsd_counter_site
|
||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->comment('weighbridge_dsd_counter', '_table', 'Compteur DSD du pont bascule par site (RG-5.04, M5 Logistique) — chaque pesee consomme une valeur. Incremente en DBAL brut sous verrou FOR UPDATE, hors ORM.');
|
||||
$this->comment('weighbridge_dsd_counter', 'site_id', 'Site proprietaire du compteur (1 pont par site). PK + FK -> site.id, ON DELETE CASCADE.');
|
||||
$this->comment('weighbridge_dsd_counter', 'last_value', 'Derniere valeur DSD attribuee pour le site (pont bascule). Increment verrouille FOR UPDATE (RG-5.04).');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Table principale `weighing_ticket`
|
||||
// =================================================================
|
||||
|
||||
private function createWeighingTicket(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE weighing_ticket (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
site_id INT NOT NULL,
|
||||
number VARCHAR(20) NOT NULL,
|
||||
counterparty_type VARCHAR(12) NOT NULL,
|
||||
client_id INT DEFAULT NULL,
|
||||
supplier_id INT DEFAULT NULL,
|
||||
other_label VARCHAR(255) DEFAULT NULL,
|
||||
immatriculation VARCHAR(20) NOT NULL,
|
||||
plate_free_format BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
empty_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
empty_weight INT DEFAULT NULL,
|
||||
empty_dsd INT DEFAULT NULL,
|
||||
empty_mode VARCHAR(8) DEFAULT NULL,
|
||||
empty_manual_number VARCHAR(50) DEFAULT NULL,
|
||||
full_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
full_weight INT DEFAULT NULL,
|
||||
full_dsd INT DEFAULT NULL,
|
||||
full_mode VARCHAR(8) DEFAULT NULL,
|
||||
full_manual_number VARCHAR(50) DEFAULT NULL,
|
||||
net_weight INT DEFAULT NULL,
|
||||
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_wt_counterparty_type
|
||||
CHECK (counterparty_type IN ('CLIENT', 'FOURNISSEUR', 'AUTRE')),
|
||||
CONSTRAINT chk_wt_empty_mode
|
||||
CHECK (empty_mode IS NULL OR empty_mode IN ('AUTO', 'MANUAL')),
|
||||
CONSTRAINT chk_wt_full_mode
|
||||
CHECK (full_mode IS NULL OR full_mode IN ('AUTO', 'MANUAL')),
|
||||
CONSTRAINT chk_wt_client_branch
|
||||
CHECK (counterparty_type <> 'CLIENT' OR (client_id IS NOT NULL AND supplier_id IS NULL AND other_label IS NULL)),
|
||||
CONSTRAINT chk_wt_supplier_branch
|
||||
CHECK (counterparty_type <> 'FOURNISSEUR' OR (supplier_id IS NOT NULL AND client_id IS NULL AND other_label IS NULL)),
|
||||
CONSTRAINT chk_wt_other_branch
|
||||
CHECK (counterparty_type <> 'AUTRE' OR (other_label IS NOT NULL AND client_id IS NULL AND supplier_id IS NULL)),
|
||||
CONSTRAINT fk_wt_site
|
||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_wt_client
|
||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_wt_supplier
|
||||
FOREIGN KEY (supplier_id) REFERENCES supplier (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_wt_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_wt_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE UNIQUE INDEX uq_weighing_ticket_number ON weighing_ticket (site_id, number)');
|
||||
$this->addSql('CREATE INDEX idx_wt_site ON weighing_ticket (site_id)');
|
||||
$this->addSql('CREATE INDEX idx_wt_client ON weighing_ticket (client_id)');
|
||||
$this->addSql('CREATE INDEX idx_wt_supplier ON weighing_ticket (supplier_id)');
|
||||
$this->addSql('CREATE INDEX idx_wt_deleted_at ON weighing_ticket (deleted_at)');
|
||||
$this->addSql('CREATE INDEX idx_wt_created_by ON weighing_ticket (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_wt_updated_by ON weighing_ticket (updated_by)');
|
||||
|
||||
$this->comment('weighing_ticket', '_table', 'Tickets de pesee (M5 Logistique) — pesee a vide + a plein au pont bascule, contrepartie Client/Fournisseur/Autre. Cloisonne par site courant.');
|
||||
$this->comment('weighing_ticket', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('weighing_ticket', 'site_id', 'Site du pont bascule (cloisonnement § 2.3). FK -> site.id, ON DELETE RESTRICT. Renseigne serveur depuis le site courant, immuable (RG-5.09).');
|
||||
$this->comment('weighing_ticket', 'number', 'Numero {siteCode}-TP-{NNNN}, unique par site (uq_weighing_ticket_number), immuable. Sequence weighing_ticket_counter (RG-5.02).');
|
||||
$this->comment('weighing_ticket', 'counterparty_type', 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (chk_wt_counterparty_type, RG-5.03). Pilote l obligation client_id / supplier_id / other_label.');
|
||||
$this->comment('weighing_ticket', 'client_id', 'Branche CLIENT (RG-5.03) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi counterparty_type = CLIENT, nul sinon (chk_wt_client_branch).');
|
||||
$this->comment('weighing_ticket', 'supplier_id', 'Branche FOURNISSEUR (RG-5.03) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi counterparty_type = FOURNISSEUR (chk_wt_supplier_branch).');
|
||||
$this->comment('weighing_ticket', 'other_label', 'Branche AUTRE (RG-5.03) : libelle libre de la contrepartie. Requis ssi counterparty_type = AUTRE, nul sinon (chk_wt_other_branch).');
|
||||
$this->comment('weighing_ticket', 'immatriculation', 'Plaque du vehicule, partagee entre pesee vide et plein. Masque XX-000-XX sauf si plate_free_format (RG-5.01). Normalisee serveur (trim/UPPER).');
|
||||
$this->comment('weighing_ticket', 'plate_free_format', '« Tout format » : desactive le masque XX-000-XX de l immatriculation (RG-5.01). Partage entre les 2 formulaires. Faux par defaut.');
|
||||
$this->comment('weighing_ticket', 'empty_date', 'Date/heure de la pesee a vide (tare). Defaut jour courant cote front (RG-5.07). Null tant que la pesee vide n est pas faite.');
|
||||
$this->comment('weighing_ticket', 'empty_weight', 'Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).');
|
||||
$this->comment('weighing_ticket', 'empty_dsd', 'Compteur DSD du pont a la pesee a vide. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).');
|
||||
$this->comment('weighing_ticket', 'empty_mode', 'Mode de la pesee a vide : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_empty_mode (RG-5.06).');
|
||||
$this->comment('weighing_ticket', 'empty_manual_number', 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a vide (RG-5.04).');
|
||||
$this->comment('weighing_ticket', 'full_date', 'Date/heure de la pesee a plein (brut). Null tant que la pesee plein n est pas faite.');
|
||||
$this->comment('weighing_ticket', 'full_weight', 'Poids a plein (brut) en kg — readonly UI, rempli par la pesee (RG-5.07).');
|
||||
$this->comment('weighing_ticket', 'full_dsd', 'Compteur DSD du pont a la pesee a plein. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).');
|
||||
$this->comment('weighing_ticket', 'full_mode', 'Mode de la pesee a plein : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_full_mode (RG-5.06).');
|
||||
$this->comment('weighing_ticket', 'full_manual_number', 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a plein (RG-5.04).');
|
||||
$this->comment('weighing_ticket', 'net_weight', 'Poids net = full_weight - empty_weight (kg), calcule serveur (RG-5.05). Null si une pesee manque. Colonne Poids de la liste.');
|
||||
$this->comment('weighing_ticket', 'deleted_at', 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.');
|
||||
$this->addTimestampableBlamableComments('weighing_ticket');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Helpers (identiques au M4 Version20260615150000)
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
|
||||
* en reutilisant le catalogue partage (source unique, ERP-67).
|
||||
*/
|
||||
private function addTimestampableBlamableComments(string $table): void
|
||||
{
|
||||
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
|
||||
$this->comment($table, $column, $description);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN`
|
||||
* en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement d apostrophe.
|
||||
*/
|
||||
private function comment(string $table, string $column, string $description): void
|
||||
{
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* M5 — Tickets de pesee (ERP-183) : finalisation de site.code en NOT NULL.
|
||||
*
|
||||
* Cadencement en 2 temps (RETEX dev ERP-182, § 2.5) :
|
||||
* - ERP-182 (Version20260617150000) a cree site.code NULLABLE + backfill +
|
||||
* index unique uq_site_code, car l'entite Site ne mappait pas encore `code`
|
||||
* (un persist ORM l'aurait omis -> violation NOT NULL au `make db-reset`).
|
||||
* - ERP-183 mappe desormais Site::code (propriete + getter/setter + derivation
|
||||
* auto du CP au prePersist) et le peuple dans SitesFixtures (86/17/82). La
|
||||
* colonne est donc systematiquement renseignee : on peut poser le NOT NULL.
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11), comme la migration
|
||||
* de schema M5 dont celle-ci depend (elle doit s'executer apres).
|
||||
*
|
||||
* Le COMMENT ON COLUMN est repose avec le texte definitif (sans la mention
|
||||
* « NOT NULL pose au ticket entite » devenue caduque), aligne sur l'entree
|
||||
* `site.code` du ColumnCommentsCatalog (chemin schema:update de la BDD de test).
|
||||
*/
|
||||
final class 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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-193 (retour metier) — l'onglet Contact d'un transporteur n'est plus
|
||||
* obligatoire : suppression de la garde « prenom OU nom » (ex RG-4.08). Drop du
|
||||
* CHECK chk_carrier_contact_name et mise a jour des commentaires de colonnes. La
|
||||
* garde applicative (CarrierContactProcessor::validateName) est retiree 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),
|
||||
* comme la migration qui avait introduit le CHECK (Version20260617120000) ; le tri
|
||||
* par version au sein du meme namespace garantit qu'elle joue APRES (cf. CLAUDE.md
|
||||
* regle 11 — le tri cross-namespace casserait l'ordre sur base vide).
|
||||
*/
|
||||
final class Version20260619120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-193 : onglet Contact transporteur non obligatoire — drop du CHECK chk_carrier_contact_name.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE carrier_contact DROP CONSTRAINT chk_carrier_contact_name');
|
||||
|
||||
$this->addSql('COMMENT ON TABLE carrier_contact IS $_$Contacts d un transporteur (1:n) — onglet Contact (M4). Bloc optionnel (ERP-193) ; max 2 telephones.$_$');
|
||||
$this->addSql('COMMENT ON COLUMN carrier_contact.first_name IS $_$Prenom du contact (capitalise serveur). Optionnel (ERP-193).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN carrier_contact.last_name IS $_$Nom du contact (capitalise serveur). Optionnel (ERP-193).$_$');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$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).$_$');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-193 (retours metier M3) : suppression du champ « Categorie » du BLOC ADRESSE
|
||||
* prestataire (ProviderAddress) — fonctionnalite jugee inutile cote metier. Seule
|
||||
* la categorie du PRESTATAIRE lui-meme (table provider_category) est conservee.
|
||||
*
|
||||
* Drop de la table de jointure M2M provider_address_category (creee par
|
||||
* Version20260612100000). Migration au namespace racine DoctrineMigrations (et non
|
||||
* modulaire Technique) : elle DEPEND d une table creee au namespace racine et doit
|
||||
* donc s executer APRES sur base vide. Le tri cross-namespace de Doctrine Migrations
|
||||
* est alphabetique par FQCN (cf. regle ABSOLUE n°11) : une migration modulaire
|
||||
* « App\... » trierait AVANT « DoctrineMigrations\... » et passerait le DROP avant
|
||||
* le CREATE (table recreee a la fin). Rester en racine garantit l ordre par version.
|
||||
*/
|
||||
final class Version20260622100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-193 : suppression de la categorie du bloc adresse prestataire (drop provider_address_category).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_address_category');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_address_category (
|
||||
provider_address_id INT NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
PRIMARY KEY (provider_address_id, category_id),
|
||||
CONSTRAINT fk_provider_address_category_address
|
||||
FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_address_category_category
|
||||
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->comment('provider_address_category', '_table', 'Jointure M2M provider_address <-> category — categories d adresse de type PRESTATAIRE (RG-3.09).');
|
||||
$this->comment('provider_address_category', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||
$this->comment('provider_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type PRESTATAIRE (RG-3.09).');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pose un COMMENT ON TABLE/COLUMN en dollar-quoting Postgres ($_$...$_$)
|
||||
* pour eviter tout echappement d'apostrophes dans les descriptions.
|
||||
*/
|
||||
private function comment(string $table, string $column, string $description): void
|
||||
{
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use App\Shared\Domain\Contract\ClientInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -162,6 +163,7 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
|
||||
#[ORM\Column(length: 180)]
|
||||
#[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom de l\'entreprise doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom de l\'entreprise ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $companyName = null;
|
||||
|
||||
@@ -214,11 +216,14 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?string $competitors = null;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
// Date de creation jamais dans le futur (retour metier ERP-193) : <= aujourd'hui.
|
||||
#[Assert\LessThanOrEqual(value: 'today', message: 'La date de création ne peut pas être dans le futur.')]
|
||||
// Format d'ENTREE strict ISO `Y-m-d` (le `!` remet l'heure a 00:00:00). Sans
|
||||
// ce format, PHP DateTime accepte des formes ambigues : « 12/25/2026 » (que
|
||||
// le front MalioDate juge invalide en JJ/MM/AAAA) serait sinon interprete en
|
||||
@@ -233,12 +238,16 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?int $employeesCount = null;
|
||||
|
||||
// Chiffre d'affaires plafonne a 999 999 999 999,99 (12 chiffres, retour metier
|
||||
// ERP-193). La colonne (decimal 15,2) tolere plus, mais le metier borne la saisie.
|
||||
#[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
|
||||
#[Assert\LessThanOrEqual(value: 999_999_999_999.99, message: 'Le chiffre d\'affaires ne peut pas dépasser 999 999 999 999,99.')]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?string $revenueAmount = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?string $directorName = null;
|
||||
|
||||
@@ -257,6 +266,7 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
|
||||
|
||||
#[ORM\Column(length: 40, nullable: true)]
|
||||
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?string $accountNumber = null;
|
||||
|
||||
@@ -267,6 +277,7 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
|
||||
|
||||
#[ORM\Column(length: 40, nullable: true)]
|
||||
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?string $nTva = null;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Shared\Domain\Contract\ClientAddressInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -158,17 +159,20 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $streetComplement = null;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -94,16 +95,19 @@ class ClientContact implements TimestampableInterface, BlamableInterface
|
||||
// deux restent nullable au niveau ORM.
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private ?string $jobTitle = null;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SupplierInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -171,6 +172,7 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
|
||||
#[ORM\Column(length: 180)]
|
||||
#[Assert\NotBlank(message: 'Le nom du fournisseur est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du fournisseur doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du fournisseur ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['supplier:read', 'supplier:write:main'])]
|
||||
private ?string $companyName = null;
|
||||
|
||||
@@ -195,11 +197,14 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['supplier:read', 'supplier:write:information'])]
|
||||
private ?string $competitors = null;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
#[Groups(['supplier:read', 'supplier:write:information'])]
|
||||
// Date de creation jamais dans le futur (retour metier ERP-193) : <= aujourd'hui.
|
||||
#[Assert\LessThanOrEqual(value: 'today', message: 'La date de création ne peut pas être dans le futur.')]
|
||||
// Format d'ENTREE strict ISO `Y-m-d` : sans lui, PHP DateTime accepte des
|
||||
// formes ambigues (« 12/25/2026 », jugee invalide par MalioDate en JJ/MM/AAAA,
|
||||
// serait lue en M/J -> 25 decembre et acceptee a tort). Avec le format, toute
|
||||
@@ -212,12 +217,16 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
|
||||
#[Groups(['supplier:read', 'supplier:write:information'])]
|
||||
private ?int $employeesCount = null;
|
||||
|
||||
// Chiffre d'affaires plafonne a 999 999 999 999,99 (12 chiffres, retour metier
|
||||
// ERP-193). La colonne (decimal 15,2) tolere plus, mais le metier borne la saisie.
|
||||
#[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
|
||||
#[Assert\LessThanOrEqual(value: 999_999_999_999.99, message: 'Le chiffre d\'affaires ne peut pas dépasser 999 999 999 999,99.')]
|
||||
#[Groups(['supplier:read', 'supplier:write:information'])]
|
||||
private ?string $revenueAmount = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['supplier:read', 'supplier:write:information'])]
|
||||
private ?string $directorName = null;
|
||||
|
||||
@@ -243,6 +252,7 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
|
||||
|
||||
#[ORM\Column(length: 40, nullable: true)]
|
||||
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
|
||||
#[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
|
||||
private ?string $accountNumber = null;
|
||||
|
||||
@@ -253,6 +263,7 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
|
||||
|
||||
#[ORM\Column(length: 40, nullable: true)]
|
||||
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
|
||||
#[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
|
||||
private ?string $nTva = null;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SupplierAddressInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -154,17 +155,20 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private ?string $streetComplement = null;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -99,16 +100,19 @@ class SupplierContact implements TimestampableInterface, BlamableInterface
|
||||
// deux restent nullable au niveau ORM.
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:contacts'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:contacts'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:contacts'])]
|
||||
private ?string $jobTitle = null;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user