Compare commits
217 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6b8e7145e | |||
| f619a6969d | |||
| 64c3b9b6ec | |||
| ce0e274743 | |||
| f12a378126 | |||
| 04008f97a9 | |||
| 086be7b4f0 | |||
| f6c556ca1b | |||
| 4207a4ae12 | |||
| fdd4394e99 | |||
| 8085f30077 | |||
| 817975e0b7 | |||
| efded9fd40 | |||
| 2e50a760c6 | |||
| 49e5e5548e | |||
| fd430bc123 | |||
| a6b48b1dd1 | |||
| 97f2402ae4 | |||
| faafd99ef8 | |||
| a4158d4e37 | |||
| 5e15c1f69f | |||
| 6c938756cc | |||
| ac1a51a7f4 | |||
| a9935fbf97 | |||
| 36e947fd8e | |||
| b4e550b5de | |||
| 10c113dbad | |||
| c0dadd79ff | |||
| 1ffa38282a | |||
| 036b075d5e | |||
| 25466b18d8 | |||
| 2fde5844e5 | |||
| 02a22597b3 | |||
| 76e7a59ba7 | |||
| e88bb059e6 | |||
| 312c119c06 | |||
| 8491f55072 | |||
| c63a5f971f | |||
| 5f2aa5334b | |||
| 21b1c64a5f | |||
| fd89160c4b | |||
| 8daf0ff5d4 | |||
| 87c53c354b | |||
| f8b45cb30b | |||
| 0c0b57f898 | |||
| c18566124a | |||
| f767733b87 | |||
| 40da5dfb31 | |||
| d304b74289 | |||
| 80b3741f64 | |||
| c468374b16 | |||
| 7ddf495d7f | |||
| 9fcf5c24f6 | |||
| 76fb01c063 | |||
| e76bd1dd63 | |||
| 498cef8cc0 | |||
| 7668d77c78 | |||
| 1d5110d000 | |||
| b6b5bb06e8 | |||
| fb9c15c52a | |||
| c371057c0b | |||
| e1712465f1 | |||
| 5125883e21 | |||
| 6ff5b13ce2 | |||
| 5bbd4ddb47 | |||
| a26bb09ee1 | |||
| 20296ac149 | |||
| 07e0bcbcce | |||
| fe1d012548 | |||
| d86dc69cf2 | |||
| 07ed57f283 | |||
| b5749520bc | |||
| 02d2fde653 | |||
| 0d284fe488 | |||
| 48ca963a9d | |||
| b11968f5e5 | |||
| 5109b5f57a | |||
| d5a01ac85f | |||
| 7adf3a511a | |||
| e612eae391 | |||
| f29266e5e8 | |||
| f27db02cb6 | |||
| 5765ba7178 | |||
| ef996c3672 | |||
| c6259a96cd | |||
| 726be37ccf | |||
| 40fdded7e2 | |||
| 4202977950 | |||
| 45158af920 | |||
| c09b3cda2b | |||
| 0733a239a8 | |||
| cf645493c1 | |||
| 388d39a379 | |||
| d6d2144cc1 | |||
| 6a519874ed | |||
| 3804362546 | |||
| 9864dbc00f | |||
| be03f4e51a | |||
| 8cc2cea444 | |||
| f70e701854 | |||
| f1b18cfbbe | |||
| 5734aaef54 | |||
| 597c63bb2e | |||
| 8046de76c6 | |||
| 1ef4215ebf | |||
| 3b474f83f5 | |||
| c60daebf3e | |||
| 6dab7cfd17 | |||
| c1fcd9a7c8 | |||
| 18c88156e5 | |||
| c0fa00c9c5 | |||
| e688fe7e0b | |||
| 7d2812cea6 | |||
| daa8224b8b | |||
| 7012306a78 | |||
| 397fb22c62 | |||
| 13d4a08bc9 | |||
| aa23189fe1 | |||
| dc75945f3e | |||
| 2be9cd05d4 | |||
| f61e189441 | |||
| 9d9f9861b1 | |||
| 39071cbec0 | |||
| b82acdac01 | |||
| 8b8fb8c2aa | |||
| f9fec3e908 | |||
| 4f8ed075b6 | |||
| 1e783bd753 | |||
| 9f4f45f761 | |||
| e99747ac72 | |||
| 36edd11854 | |||
| 45cb5c834c | |||
| 2689b85ebe | |||
| f4bbc79550 | |||
| f057866e75 | |||
| 19fdb50cec | |||
| 368bb50ffb | |||
| 6a83adc00a | |||
| c76c447aa2 | |||
| 19ac8833eb | |||
| c25c33116d | |||
| 17aa61d014 | |||
| 3d4ae391fe | |||
| 04c794addb | |||
| c1e45cd582 | |||
| a6f01400ba | |||
| d0e9f48983 | |||
| c1206fa29c | |||
| 090ea5eb49 | |||
| ee1f344764 | |||
| 3fe0f676f6 | |||
| d5462bcf42 | |||
| 54d8327fa5 | |||
| 09a4b9d464 | |||
| d97b9ce6d0 | |||
| b36520d3b1 | |||
| a340d8139a | |||
| 7d8a633eee | |||
| df9451a5f4 | |||
| cb12490ba0 | |||
| a442d124a3 | |||
| 431d831c8b | |||
| 3f356f0679 | |||
| c1ce940c98 | |||
| c594a76d47 | |||
| 59bae8c5e6 | |||
| 477f77a6b5 | |||
| d6790dd37d | |||
| 3c1fc39eee | |||
| 1b0339bf1c | |||
| cc7a657df9 | |||
| d72f67d374 | |||
| 26b1f2c39b | |||
| 8490de99da | |||
| b3ab23ee8f | |||
| 222338e5a4 | |||
| d4a5df50a7 | |||
| 191fd42406 | |||
| edfb2b1619 | |||
| c5c650c599 | |||
| e598a92f94 | |||
| b8dc3cb696 | |||
| 843e4b0a0c | |||
| a9c14704b7 | |||
| 43b2251ef1 | |||
| 9cda225bdf | |||
| f031c70393 | |||
| e050a7b910 | |||
| b35deed8fe | |||
| 6f9bb68170 | |||
| 97459e798f | |||
| 58cbfe4437 | |||
| 54091be60e | |||
| e265a008bc | |||
| 145d4362db | |||
| cd36c45b67 | |||
| e77c6378d3 | |||
| 3e138e1c17 | |||
| 6a01067746 | |||
| cd98817b0a | |||
| 1a29bcf76c | |||
| da343464c6 | |||
| 0b33bcb0f2 | |||
| 786638a02f | |||
| fcacde2a34 | |||
| fea325e10f | |||
| e139d234a9 | |||
| c437bc52a2 | |||
| 597101262d | |||
| 90dfc17fcb | |||
| ce89c5e46a | |||
| 546ba462b9 | |||
| ee3bbea649 | |||
| e85d46a17b | |||
| ec952896ba | |||
| 468894cfad | |||
| 912280d24e |
+19
-131
@@ -6,6 +6,13 @@
|
||||
- PHP CS Fixer : regles Symfony + PSR-12 + strict types (commande : `make php-cs-fixer-allow-risky`)
|
||||
- Commentaires (docblock, inline, bloc) **en francais** ; code (classes, methodes, variables) en anglais
|
||||
|
||||
## Messages de validation (obligatoire)
|
||||
|
||||
Toute contrainte `#[Assert\*]` d'une entite metier : **message FR explicite**, et `Assert\Length.max` = `length` de la colonne ORM (coherence 3 niveaux nullable DB <-> NotBlank back <-> required front, ERP-101). RG inter-champs via `#[Assert\Callback]->atPath('<champ>')` (mapping inline front, pas toast). Exceptions miroir Length (Bic/Iban/Regex borne) : whitelist `EntityConstraintsHaveFrenchMessageTest::EXCLUDED_LENGTH_MIRROR`.
|
||||
|
||||
Garde-fou : `tests/Architecture/EntityConstraintsHaveFrenchMessageTest` (casse `make test`).
|
||||
→ patterns code + exemples + justification complete : skill `backend-entity-conventions`.
|
||||
|
||||
## API Platform (pas de controllers)
|
||||
|
||||
- Toujours utiliser `#[ApiResource]` + Providers + Processors — pas de controllers Symfony classiques
|
||||
@@ -15,61 +22,10 @@
|
||||
|
||||
## Pagination (obligatoire)
|
||||
|
||||
**Regle** : toute collection API DOIT etre paginee. Aucun retour de collection complete cote serveur.
|
||||
Toute collection API est paginee (defaut 10, max 50 ; `?pagination=false` = echappatoire selects, `?itemsPerPage=25` borne par le max). Standard global dans `config/packages/api_platform.yaml`. Jamais `paginationEnabled: false` hors whitelist `CollectionsArePaginatedTest::EXCLUDED`. Provider custom : ne jamais retourner un `array` brut sur une `CollectionOperationInterface` (court-circuite Hydra) — wrapper un Paginator (ORM : `ApiPlatform\Doctrine\Orm\Paginator` ; DBAL : `DbalPaginator`) et gerer `?pagination=false` via `$this->pagination->isEnabled(...)`.
|
||||
|
||||
### Standard global
|
||||
|
||||
Pose dans `config/packages/api_platform.yaml` (section `defaults:`) et heritee par toutes les ressources :
|
||||
|
||||
| Cle | Valeur | Effet |
|
||||
|---|---|---|
|
||||
| `pagination_enabled` | `true` | Pagination Hydra active par defaut. |
|
||||
| `pagination_items_per_page` | `10` | Taille de page par defaut, aligne sur l'UI `MalioDataTable`. |
|
||||
| `pagination_maximum_items_per_page` | `50` | Borne dure : `?itemsPerPage=999` → ramene a 50. Anti deep-fetch. |
|
||||
| `pagination_client_items_per_page` | `true` | Le client peut envoyer `?itemsPerPage=25` (bornee par le max). |
|
||||
| `pagination_client_enabled` | `true` | Le client peut envoyer `?pagination=false` pour TOUT recuperer (echappatoire selects). |
|
||||
|
||||
### Override par ressource (rare)
|
||||
|
||||
Si une ressource a besoin d'un autre defaut (ex: payload lourd), utiliser les attributs sur l'operation. **JAMAIS `paginationEnabled: false`** sans whitelist explicite dans `tests/Architecture/CollectionsArePaginatedTest::EXCLUDED`.
|
||||
|
||||
```php
|
||||
new GetCollection(
|
||||
paginationItemsPerPage: 5, // override taille par defaut
|
||||
paginationMaximumItemsPerPage: 20, // override borne max
|
||||
)
|
||||
```
|
||||
|
||||
### Selects et autocompletions
|
||||
|
||||
Pour alimenter un `<select>` ou un drawer RBAC (Role, Permission, Site, CategoryType), le front passe :
|
||||
|
||||
```ts
|
||||
useApi().get('/api/roles?pagination=false')
|
||||
```
|
||||
|
||||
Le serveur retourne toute la collection, sans `view`. C'est l'echappatoire prevue par `pagination_client_enabled: true`. Sur les ressources a forte volumetrie, preferer une saisie assistee (recherche serveur via `?q=`) — a planifier dans un ticket dedie.
|
||||
|
||||
Les tests fonctionnels qui exercent ce comportement doivent egalement passer `?pagination=false` (cf. `CategoryListTest`, `PermissionApiTest`).
|
||||
|
||||
### Providers customs et pagination
|
||||
|
||||
Un provider custom qui retourne un `array` brut sur une `CollectionOperationInterface` **court-circuite la pagination Hydra** (pas de `totalItems`, pas de `view`). Patterns supportes :
|
||||
|
||||
- **ORM** : injecter `ApiPlatform\State\Pagination\Pagination`, wrap un `Doctrine\ORM\Tools\Pagination\Paginator` dans `ApiPlatform\Doctrine\Orm\Paginator`. Exemple : `CategoryProvider`.
|
||||
- **DBAL** : implementer un paginator local conforme a `PaginatorInterface`. Exemple : `DbalPaginator` (Core) + `AuditLogProvider`.
|
||||
|
||||
Gerer l'echappatoire `?pagination=false` :
|
||||
|
||||
```php
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
return $qb->getQuery()->getResult(); // tout retourner
|
||||
}
|
||||
```
|
||||
|
||||
### Garde-fou architecture
|
||||
|
||||
`tests/Architecture/CollectionsArePaginatedTest` scanne reflexivement toutes les classes `#[ApiResource]` sous `src/` et echoue si une `GetCollection` pose `paginationEnabled: false` hors whitelist `EXCLUDED`. Ajouter une entree a la whitelist requiert une justification courte + un ticket Lesstime ouvert.
|
||||
Garde-fou : `tests/Architecture/CollectionsArePaginatedTest` (casse `make test`).
|
||||
→ tableau des cles `pagination_*` + selects + providers ORM/DBAL detailles : skill `backend-entity-conventions`.
|
||||
|
||||
## Repositories
|
||||
|
||||
@@ -100,42 +56,17 @@ Format obligatoire : `module.resource[.subresource].action` en snake_case.
|
||||
|
||||
### Libelle i18n du type d'entite (obligatoire avec `#[Auditable]`)
|
||||
|
||||
**Toute entite `#[Auditable]` doit avoir son libelle FR dans le bloc `audit.entity` de `frontend/i18n/locales/fr.json`.** C'est la contrepartie i18n de l'attribut : sans elle, le filtre « Type d'entite » de l'audit-log affiche le type technique brut (ex: `commercial.Client`) au lieu d'un libelle lisible.
|
||||
Toute entite `#[Auditable]` doit avoir sa cle `audit.entity.<module>_<entity>` dans `frontend/i18n/locales/fr.json` (cle = `strtolower(module)` + `_` + `strtolower(Entity)`, decision ERP-99). Sans elle, le filtre « Type d'entite » de l'audit-log retombe silencieusement sur le type technique brut (ex: `commercial.Client`). Fait partie de la definition de fini d'une entite auditee.
|
||||
|
||||
Pourquoi : le filtre est dynamique (`GET /audit-log-entity-types` renvoie les `entity_type` distincts presents en base) ; des qu'un module audite une entite, son type y apparait. Le front (`formatEntityType`, `audit-log.vue`) construit la cle `audit.entity.<module>_<entity>` et, faute de traduction, **retombe silencieusement** sur le type brut.
|
||||
|
||||
Derivation de la cle (emplacement centralise + schema flat — decision ERP-99) :
|
||||
|
||||
| FQCN entite | `entity_type` (back) | Cle i18n (flat) |
|
||||
|---|---|---|
|
||||
| `App\Module\Commercial\Domain\Entity\Client` | `commercial.Client` | `commercial_client` |
|
||||
| `App\Module\Commercial\Domain\Entity\ClientAddress` | `commercial.ClientAddress` | `commercial_clientaddress` |
|
||||
| `App\Module\Catalog\Domain\Entity\Category` | `catalog.Category` | `catalog_category` |
|
||||
|
||||
Regle : `strtolower(module)` + `_` + `strtolower(Entity)`. Ajouter sa cle de libelle audit fait partie de la **definition de fini** d'une entite metier auditee.
|
||||
|
||||
**Garde-fou** : `tests/Architecture/AuditableEntitiesHaveI18nLabelTest` scanne les entites `#[Auditable]` et echoue si une seule n'a pas sa cle `audit.entity.*`. Conclusion : creer une entite `#[Auditable]` sans son libelle i18n casse `make test`.
|
||||
Garde-fou : `tests/Architecture/AuditableEntitiesHaveI18nLabelTest` (casse `make test`).
|
||||
→ derivation detaillee + exemples : skill `backend-entity-conventions`.
|
||||
|
||||
## Timestampable + Blamable (obligatoire pour entites metier)
|
||||
|
||||
Toute **nouvelle** entite metier sous `src/Module/*/Domain/Entity/` doit porter les 4 colonnes `created_at` / `updated_at` / `created_by` / `updated_by`, remplies automatiquement. Trois lignes a ajouter a l'entite :
|
||||
Toute **nouvelle** entite metier sous `src/Module/*/Domain/Entity/` : `implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait` (porte les 4 colonnes `created_at` / `updated_at` / `created_by` / `updated_by`, remplies par `TimestampableBlamableSubscriber` au prePersist/preUpdate). La migration cree les 4 colonnes (`created_at`/`updated_at` NOT NULL, `created_by`/`updated_by` nullable `ON DELETE SET NULL`). Referentiel statique justifie : whitelist `EntitiesAreTimestampableBlamableTest::EXCLUDED`.
|
||||
|
||||
```php
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
|
||||
class MyEntity implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait; // porte les 4 props + getters/setters
|
||||
// ... reste metier
|
||||
}
|
||||
```
|
||||
|
||||
- Le `TimestampableBlamableSubscriber` (`Shared/Infrastructure/Doctrine/`) remplit les colonnes au `prePersist` / `preUpdate`. Hors contexte HTTP (CLI, cron, migration), le blame reste `null` (libelle « Systeme » cote front).
|
||||
- La migration de l'entite doit creer les 4 colonnes (`created_at` / `updated_at` NOT NULL, `created_by` / `updated_by` nullable `ON DELETE SET NULL`).
|
||||
- **Garde-fou CI** : `tests/Architecture/EntitiesAreTimestampableBlamableTest` echoue si une entite oublie le pattern. Un referentiel statique justifie (ex: `CategoryType`) doit etre explicitement whiteliste dans la constante `EXCLUDED` avec un commentaire.
|
||||
- Spec complete : @docs/specs/M0-categories/spec-back.md § 2.8 + § 2.8.bis
|
||||
Garde-fou : `tests/Architecture/EntitiesAreTimestampableBlamableTest` (casse `make test`).
|
||||
→ snippet complet : skill `backend-entity-conventions` ; spec : @docs/specs/M0-categories/spec-back.md § 2.8 + § 2.8.bis.
|
||||
|
||||
## Serialization
|
||||
|
||||
@@ -153,50 +84,7 @@ Exemple : pour qu'`User.profile` soit embarque au lieu d'un lien IRI sous le gro
|
||||
|
||||
## Migrations Doctrine
|
||||
|
||||
### Documentation SQL obligatoire (`COMMENT ON COLUMN`)
|
||||
Toute migration creant/modifiant une colonne d'une table metier pose un `COMMENT ON COLUMN` (FR, ≤ 200 caracteres, semantique + contrainte/RG, cible pour les FK). Les 4 colonnes Timestampable/Blamable recoivent leur description via le helper centralise `addStandardTimestampableBlamableComments($schema, 'table')`. Bonus : `COMMENT ON TABLE` pour decrire la table.
|
||||
|
||||
**Toute migration qui cree ou modifie une colonne d'une table metier doit poser un `COMMENT ON COLUMN` decrivant le champ.** La description est stockee dans `pg_description` et visible dans tous les outils d'admin BDD (DBeaver, DataGrip, pgAdmin), sans avoir a lire les annotations PHP.
|
||||
|
||||
**Format de la description** :
|
||||
- En francais
|
||||
- ≤ 200 caracteres
|
||||
- Semantique du champ — contraintes / lien RG si pertinent
|
||||
- Pour les colonnes d'identifiant ou FK, mentionner la cible
|
||||
|
||||
Exemples :
|
||||
|
||||
```php
|
||||
// Migration : creation d'une colonne avec son commentaire dans la meme migration
|
||||
$this->addSql("ALTER TABLE client ADD COLUMN siren VARCHAR(9) DEFAULT NULL");
|
||||
$this->addSql("COMMENT ON COLUMN client.siren IS 'SIREN (9 chiffres) — identifiant legal entreprise. Unique parmi non-archives (RG-1.15).'");
|
||||
|
||||
// Cas FK : preciser la cible
|
||||
$this->addSql("COMMENT ON COLUMN client.legal_form_id IS 'Reference forme juridique (SARL, SAS, SA...) — FK -> legal_form.id, ON DELETE RESTRICT.'");
|
||||
|
||||
// Cas booleen : preciser le sens et la valeur par defaut
|
||||
$this->addSql("COMMENT ON COLUMN user.is_admin IS 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.'");
|
||||
|
||||
// Bonus : decrire la table elle-meme
|
||||
$this->addSql("COMMENT ON TABLE client IS 'Repertoire clients (M1 Commercial) — entites archivables.'");
|
||||
```
|
||||
|
||||
### Helper Timestampable/Blamable
|
||||
|
||||
Les 4 colonnes `created_at`, `updated_at`, `created_by`, `updated_by` ajoutees par `TimestampableBlamableTrait` recoivent une description **standardisee** via le helper centralise pour eviter la duplication. Helper a creer ou appeler :
|
||||
|
||||
```php
|
||||
// Dans la migration, apres avoir ajoute les 4 colonnes :
|
||||
$this->addStandardTimestampableBlamableComments($schema, 'client');
|
||||
```
|
||||
|
||||
L'implementation du helper applique :
|
||||
- `created_at` : « Horodatage de creation de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). »
|
||||
- `updated_at` : « Horodatage de derniere modification de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). »
|
||||
- `created_by` : « ID de l'utilisateur ayant cree la ligne — null pour les creations hors HTTP (CLI, migration, fixture). FK -> user.id, ON DELETE SET NULL. »
|
||||
- `updated_by` : « ID de l'utilisateur ayant modifie la ligne en dernier — null pour les modifications hors HTTP. FK -> user.id, ON DELETE SET NULL. »
|
||||
|
||||
### Garde-fou architecture
|
||||
|
||||
`tests/Architecture/ColumnsHaveSqlCommentTest` parcourt `information_schema.columns` filtre sur le schema `public` et echoue si **une seule colonne** n'a pas de `col_description`. Seules les tables system (`doctrine_migration_versions`) et la whitelist `EXCLUDED_TABLES` explicite (commentaire de justification + ticket Lesstime ouvert pour le retrofit) sont tolerees.
|
||||
|
||||
Conclusion : si tu crees une colonne sans poser son `COMMENT ON COLUMN`, `make test` casse en CI.
|
||||
Garde-fou : `tests/Architecture/ColumnsHaveSqlCommentTest` parcourt `information_schema.columns` (schema `public`) ; une seule colonne sans `col_description` casse `make test` (hors `EXCLUDED_TABLES`).
|
||||
→ exemples SQL + textes du helper : skill `backend-entity-conventions`.
|
||||
|
||||
@@ -44,6 +44,45 @@ Tout champ de formulaire / filtre doit utiliser les composants `Malio*` plutot q
|
||||
|
||||
Toute autre exception requiert validation avant merge.
|
||||
|
||||
## Standard ecran formulaire — reference : ecran Client
|
||||
|
||||
**Tout nouvel ecran de formulaire doit ressembler au premier ecran Client** (`frontend/modules/commercial/pages/clients/new.vue` + `[id]/edit.vue`) : meme structure (bloc principal puis onglets), memes marges/espacements, memes blocs de collection (ajout/suppression inline), meme validation inline 422 par champ. C'est la reference visuelle et fonctionnelle des formulaires du projet — s'en inspirer avant d'en creer un nouveau.
|
||||
|
||||
## Validation des formulaires — useFormErrors obligatoire (erreur par champ)
|
||||
|
||||
**Tout formulaire qui soumet a une API DOIT afficher les erreurs de validation 422 sous le champ concerne, via `useFormErrors`** (`frontend/shared/composables/useFormErrors.ts`). C'est le pendant front de « le back renvoie TOUTES les violations d'une 422 d'un coup » : un seul aller-retour, chaque erreur affichee inline sous son champ (prop `:error` des `Malio*`), pas un toast fourre-tout.
|
||||
|
||||
Principe cle : **le nom du champ cote front = le `propertyPath` renvoye par le back**. Aucun mapping manuel champ par champ.
|
||||
|
||||
Pattern de reference (champs scalaires) :
|
||||
|
||||
```ts
|
||||
const { errors, setError, clearErrors, handleApiError } = useFormErrors()
|
||||
|
||||
async function submit() {
|
||||
clearErrors()
|
||||
try {
|
||||
await useApi().post('/clients', payload, { toast: false }) // toast: false obligatoire
|
||||
} catch (e) {
|
||||
// 422 → mapping inline par champ (pas de toast) ; autre → toast de fallback.
|
||||
handleApiError(e, { fallbackMessage: t('foo.error') })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<MalioInputText v-model="form.companyName" :error="errors.companyName" />
|
||||
<MalioSelect v-model="form.siren" :error="errors.siren" />
|
||||
```
|
||||
|
||||
Regles :
|
||||
- **Toujours `{ toast: false }`** sur l'appel API qui veut un mapping inline (sinon le toast natif d'`useApi` masque le fin).
|
||||
- **Cas metier specifique** (ex: 409 doublon) : `setError('champ', message)` + toast explicite **avant** de deleguer le reste a `handleApiError`. Cf. `useCategoryForm` (doublon RG-1.07).
|
||||
- **Collections** (listes de sous-entites sauvees par un appel par ligne) : une erreur PAR LIGNE via un tableau `ref<Record<string, string>[]>` aligne sur l'index, peuple par `mapViolationsToRecord(error.response._data)` (util pur de `shared/utils/api.ts`). Le composant de ligne expose une prop `:errors` (`Record<string, string>`) bindee sur le `:error` de chaque champ. Cf. `ClientContactBlock` / `ClientAddressBlock` et les submits de `clients/new.vue` / `clients/[id]/edit.vue`.
|
||||
- **Message back technique → surcharge i18n par code** : la plupart des contraintes back portent un message FR explicite (affiche tel quel). Mais une 422 peut porter un message TECHNIQUE non montrable (ex. erreur de type API Platform sur une date non parsable : « Cette valeur doit être de type DateTimeImmutable|null. », voire en anglais selon la negociation). On le surcharge **cote front** via le `code` de violation (UUID Symfony fige, robuste — pas un match sur le texte) : table `VIOLATION_MESSAGE_I18N` + `resolveViolationMessage` dans `shared/utils/api.ts`, appliquee par `useFormErrors`. Ajouter un cas = une entree `code -> cle i18n`. Cas reference : date invalide (MalioDate forwarde la saisie brute via `@update:rawValue`, le back renvoie 422 sur `foundedAt` grace a `collectDenormalizationErrors`, le front affiche `errors.validation.invalidDate`).
|
||||
|
||||
**Interdit** : se contenter d'un toast global sur une 422 quand le back identifie les champs fautifs (`propertyPath`). Reimplementer un mapping `if/else` par champ a la main au lieu d'`useFormErrors` / `mapViolationsToRecord`.
|
||||
|
||||
## Tableaux de donnees — MalioDataTable obligatoire
|
||||
|
||||
Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer par `MalioDataTable` :
|
||||
@@ -108,6 +147,18 @@ A NE PAS faire :
|
||||
- Seuls les deep links "de navigation metier" (ex: ouvrir un detail precis `/users/42`) sont dans l'URL
|
||||
- Exceptions autorisees **sur demande explicite** de l'utilisateur
|
||||
|
||||
## Validation des formulaires (standard ERP-101)
|
||||
|
||||
Regle transverse a TOUS les formulaires front (et a rappeler a l'ecriture de chaque ticket back/front portant un formulaire). Decidee en ERP-101 (declencheur : ecran « Ajouter un client » ERP-63).
|
||||
|
||||
- **Champs obligatoires** : prop `required` du composant `Malio*` + etoile (asterisque) rouge dans le label. Ne JAMAIS griser le bouton « Valider » sans feedback : bouton toujours actif + erreurs affichees sous les champs.
|
||||
- **Couche de validation autoritaire = le back** : les RG sont re-validees serveur (mode strict). Au `422`, mapper `violations[].propertyPath` vers la prop `error` du champ via `extractApiViolations` (deja utilise par `useCategoryForm`). Zero duplication de RG, zero drift.
|
||||
- **Feedback instantane au blur** : uniquement requis / min / max / format (pas de re-implementation des RG metier cote front).
|
||||
- **Regles front-only** : celles sans equivalent back (ex. FK nullable cote back mais obligatoire selon un choix UI) sont validees et affichees cote front.
|
||||
- **Email — PAS de masque** : un email n'a pas de structure fixe. Normalisation via la prop `lowercase` de `MalioInputEmail` (trim + suppression des espaces + lowercase, coherent avec la normalisation serveur RG-1.21). Le format est valide par la prop `error` (violations serveur ou check au blur), jamais par un masque. Retirer tout shaping email ad hoc des ecrans.
|
||||
- **Contrat back attendu** : tout `422` issu d'un Processor/Validator doit porter `violations[].propertyPath` aligne sur les noms de champs du formulaire, pour etre consommable par `extractApiViolations`.
|
||||
- **Dependance** : le branchement des props `required` suppose `@malio/layer-ui` a jour (props `required` + etoile — MUI-41 / ERP-101).
|
||||
|
||||
## Interdits
|
||||
|
||||
- `modules-loader.ts`, `.module.ts` — le scan des layers est automatique
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
---
|
||||
name: backend-entity-conventions
|
||||
description: Conventions détaillées des entités métier Starseed (back PHP/Symfony/API Platform) — messages de validation FR sur les contraintes, pagination API Platform et providers ORM/DBAL, libellé i18n du type d'entité auditée, Timestampable/Blamable, COMMENT ON COLUMN des migrations. Charger dès qu'on crée ou modifie une entité Domain, un ApiResource, un Provider/Processor, une contrainte de validation, ou une migration Doctrine. Le résumé court de chaque règle (+ nom du test garde-fou) reste dans .claude/rules/backend.md ; ce skill porte les patterns, tableaux et exemples complets.
|
||||
---
|
||||
|
||||
# Conventions entités métier — détail
|
||||
|
||||
Ce skill contient le détail (patterns code, tableaux, dérivations) des 5 règles back qui ont chacune
|
||||
un test Architecture déterministe. L'énoncé court de chaque règle vit dans `.claude/rules/backend.md`
|
||||
(chargé à chaque session) ; ici on trouve le « comment » complet.
|
||||
|
||||
> Règle d'or : le **test Architecture reste le juge** (il casse `make test`). Ce skill aide à écrire
|
||||
> le code juste du premier coup, il ne remplace pas le garde-fou.
|
||||
|
||||
---
|
||||
|
||||
## 1. Messages de validation (Garde-fou : `EntityConstraintsHaveFrenchMessageTest`)
|
||||
|
||||
**Toute contrainte `#[Assert\*]` portée par une entité métier doit avoir un message FR explicite**, et
|
||||
**`Assert\Length.max` doit refléter le `length` de la colonne ORM**. C'est le pendant back du mapping
|
||||
d'erreur par champ côté front (ERP-101 : `useFormErrors` / `mapViolationsToRecord` affiche sous chaque
|
||||
champ le `message` renvoyé par le back).
|
||||
|
||||
Pourquoi :
|
||||
- Sans `message:` explicite, Symfony renvoie le défaut **anglais** (« This value is not a valid email
|
||||
address. »). La locale FR globale (`default_locale: fr` dans `framework.yaml`) sert de FILET via
|
||||
`validators.fr.xlf`, mais les contraintes métier portent en plus leur message FR pour un contrôle total.
|
||||
- Une colonne string bornée **sans `Assert\Length`** échoue au niveau Postgres (500 générique, non
|
||||
rattachée au champ) au lieu d'une 422 propre. Le `max` doit égaler le `length` ORM (anti-dérive).
|
||||
|
||||
Pattern par champ scalaire :
|
||||
|
||||
```php
|
||||
// Email métier
|
||||
#[Assert\Email(message: 'L\'adresse email n\'est pas valide.')]
|
||||
|
||||
// Longueur calée sur la colonne (VARCHAR(120))
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
|
||||
// Obligatoire (aligner nullable DB / NotBlank back / required front)
|
||||
#[Assert\NotBlank(message: 'Le téléphone est obligatoire.', normalizer: 'trim')]
|
||||
```
|
||||
|
||||
Cohérence à 3 niveaux pour un champ obligatoire : colonne `nullable` (DB) <-> `Assert\NotBlank` (back)
|
||||
<-> `:required` + astérisque (front ERP-101). Les trois doivent s'accorder.
|
||||
|
||||
Exceptions au miroir `Length` : un format déjà borné par `Assert\Bic` / `Assert\Iban` (longueur
|
||||
garantie) ou par un `Assert\Regex` borné (ex. code postal `{4,5}`, couleur hex `#RRGGBB`) — whitelister
|
||||
alors la propriété dans `EntityConstraintsHaveFrenchMessageTest::EXCLUDED_LENGTH_MIRROR` avec justification.
|
||||
|
||||
Les règles inter-champs (RG métier : exclusivité distributor/broker RG-1.03, billingEmail RG-1.11, etc.)
|
||||
passent par un `#[Assert\Callback]` qui construit la violation avec `->atPath('<champ>')` — indispensable
|
||||
pour que le front la mappe en inline plutôt qu'en toast.
|
||||
|
||||
### Garde-fou architecture
|
||||
|
||||
`tests/Architecture/EntityConstraintsHaveFrenchMessageTest` scanne réflexivement les entités sous
|
||||
`src/Module/*/Domain/Entity/` et échoue si :
|
||||
1. une contrainte connue n'a pas de message FR explicite (comparé au défaut Symfony) ;
|
||||
2. une colonne string bornée writable n'a pas de `Assert\Length(max == ORM length)` (hors whitelist).
|
||||
|
||||
Une contrainte non gérée par le mapping du test le fait échouer : il faut l'ajouter explicitement
|
||||
(anti faux positif vert).
|
||||
|
||||
---
|
||||
|
||||
## 2. Pagination (Garde-fou : `CollectionsArePaginatedTest`)
|
||||
|
||||
**Règle** : toute collection API DOIT être paginée. Aucun retour de collection complète côté serveur.
|
||||
|
||||
### Standard global
|
||||
|
||||
Posé dans `config/packages/api_platform.yaml` (section `defaults:`) et hérité par toutes les ressources :
|
||||
|
||||
| Clé | Valeur | Effet |
|
||||
|---|---|---|
|
||||
| `pagination_enabled` | `true` | Pagination Hydra active par défaut. |
|
||||
| `pagination_items_per_page` | `10` | Taille de page par défaut, alignée sur l'UI `MalioDataTable`. |
|
||||
| `pagination_maximum_items_per_page` | `50` | Borne dure : `?itemsPerPage=999` → ramené à 50. Anti deep-fetch. |
|
||||
| `pagination_client_items_per_page` | `true` | Le client peut envoyer `?itemsPerPage=25` (bornée par le max). |
|
||||
| `pagination_client_enabled` | `true` | Le client peut envoyer `?pagination=false` pour TOUT récupérer (échappatoire selects). |
|
||||
|
||||
### Override par ressource (rare)
|
||||
|
||||
Si une ressource a besoin d'un autre défaut (ex: payload lourd), utiliser les attributs sur l'opération.
|
||||
**JAMAIS `paginationEnabled: false`** sans whitelist explicite dans
|
||||
`tests/Architecture/CollectionsArePaginatedTest::EXCLUDED`.
|
||||
|
||||
```php
|
||||
new GetCollection(
|
||||
paginationItemsPerPage: 5, // override taille par défaut
|
||||
paginationMaximumItemsPerPage: 20, // override borne max
|
||||
)
|
||||
```
|
||||
|
||||
### Selects et autocomplétions
|
||||
|
||||
Pour alimenter un `<select>` ou un drawer RBAC (Role, Permission, Site, CategoryType), le front passe :
|
||||
|
||||
```ts
|
||||
useApi().get('/api/roles?pagination=false')
|
||||
```
|
||||
|
||||
Le serveur retourne toute la collection, sans `view`. C'est l'échappatoire prévue par
|
||||
`pagination_client_enabled: true`. Sur les ressources à forte volumétrie, préférer une saisie assistée
|
||||
(recherche serveur via `?q=`) — à planifier dans un ticket dédié.
|
||||
|
||||
Les tests fonctionnels qui exercent ce comportement doivent également passer `?pagination=false`
|
||||
(cf. `CategoryListTest`, `PermissionApiTest`).
|
||||
|
||||
### Providers customs et pagination
|
||||
|
||||
Un provider custom qui retourne un `array` brut sur une `CollectionOperationInterface`
|
||||
**court-circuite la pagination Hydra** (pas de `totalItems`, pas de `view`). Patterns supportés :
|
||||
|
||||
- **ORM** : injecter `ApiPlatform\State\Pagination\Pagination`, wrap un
|
||||
`Doctrine\ORM\Tools\Pagination\Paginator` dans `ApiPlatform\Doctrine\Orm\Paginator`. Exemple : `CategoryProvider`.
|
||||
- **DBAL** : implémenter un paginator local conforme à `PaginatorInterface`. Exemple : `DbalPaginator`
|
||||
(Core) + `AuditLogProvider`.
|
||||
|
||||
Gérer l'échappatoire `?pagination=false` :
|
||||
|
||||
```php
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
return $qb->getQuery()->getResult(); // tout retourner
|
||||
}
|
||||
```
|
||||
|
||||
### Garde-fou architecture
|
||||
|
||||
`tests/Architecture/CollectionsArePaginatedTest` scanne réflexivement toutes les classes
|
||||
`#[ApiResource]` sous `src/` et échoue si une `GetCollection` pose `paginationEnabled: false` hors
|
||||
whitelist `EXCLUDED`. Ajouter une entrée à la whitelist requiert une justification courte + un ticket
|
||||
Lesstime ouvert.
|
||||
|
||||
---
|
||||
|
||||
## 3. Libellé i18n du type d'entité auditée (Garde-fou : `AuditableEntitiesHaveI18nLabelTest`)
|
||||
|
||||
**Toute entité `#[Auditable]` doit avoir son libellé FR dans le bloc `audit.entity` de
|
||||
`frontend/i18n/locales/fr.json`.** C'est la contrepartie i18n de l'attribut : sans elle, le filtre
|
||||
« Type d'entité » de l'audit-log affiche le type technique brut (ex: `commercial.Client`) au lieu d'un
|
||||
libellé lisible.
|
||||
|
||||
Pourquoi : le filtre est dynamique (`GET /audit-log-entity-types` renvoie les `entity_type` distincts
|
||||
présents en base) ; dès qu'un module audite une entité, son type y apparaît. Le front
|
||||
(`formatEntityType`, `audit-log.vue`) construit la clé `audit.entity.<module>_<entity>` et, faute de
|
||||
traduction, **retombe silencieusement** sur le type brut.
|
||||
|
||||
Dérivation de la clé (emplacement centralisé + schéma flat — décision ERP-99) :
|
||||
|
||||
| FQCN entité | `entity_type` (back) | Clé i18n (flat) |
|
||||
|---|---|---|
|
||||
| `App\Module\Commercial\Domain\Entity\Client` | `commercial.Client` | `commercial_client` |
|
||||
| `App\Module\Commercial\Domain\Entity\ClientAddress` | `commercial.ClientAddress` | `commercial_clientaddress` |
|
||||
| `App\Module\Catalog\Domain\Entity\Category` | `catalog.Category` | `catalog_category` |
|
||||
|
||||
Règle : `strtolower(module)` + `_` + `strtolower(Entity)`. Ajouter sa clé de libellé audit fait partie
|
||||
de la **définition de fini** d'une entité métier auditée.
|
||||
|
||||
**Garde-fou** : `tests/Architecture/AuditableEntitiesHaveI18nLabelTest` scanne les entités `#[Auditable]`
|
||||
et échoue si une seule n'a pas sa clé `audit.entity.*`. Conclusion : créer une entité `#[Auditable]`
|
||||
sans son libellé i18n casse `make test`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Timestampable + Blamable (Garde-fou : `EntitiesAreTimestampableBlamableTest`)
|
||||
|
||||
Toute **nouvelle** entité métier sous `src/Module/*/Domain/Entity/` doit porter les 4 colonnes
|
||||
`created_at` / `updated_at` / `created_by` / `updated_by`, remplies automatiquement. Trois lignes à
|
||||
ajouter à l'entité :
|
||||
|
||||
```php
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
|
||||
class MyEntity implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait; // porte les 4 props + getters/setters
|
||||
// ... reste métier
|
||||
}
|
||||
```
|
||||
|
||||
- Le `TimestampableBlamableSubscriber` (`Shared/Infrastructure/Doctrine/`) remplit les colonnes au
|
||||
`prePersist` / `preUpdate`. Hors contexte HTTP (CLI, cron, migration), le blame reste `null`
|
||||
(libellé « Système » côté front).
|
||||
- La migration de l'entité doit créer les 4 colonnes (`created_at` / `updated_at` NOT NULL,
|
||||
`created_by` / `updated_by` nullable `ON DELETE SET NULL`).
|
||||
- **Garde-fou CI** : `tests/Architecture/EntitiesAreTimestampableBlamableTest` échoue si une entité
|
||||
oublie le pattern. Un référentiel statique justifié (ex: `CategoryType`) doit être explicitement
|
||||
whitelisté dans la constante `EXCLUDED` avec un commentaire.
|
||||
- Spec complète : @docs/specs/M0-categories/spec-back.md § 2.8 + § 2.8.bis
|
||||
|
||||
---
|
||||
|
||||
## 5. Migrations Doctrine — COMMENT ON COLUMN (Garde-fou : `ColumnsHaveSqlCommentTest`)
|
||||
|
||||
**Toute migration qui crée ou modifie une colonne d'une table métier doit poser un `COMMENT ON COLUMN`
|
||||
décrivant le champ.** La description est stockée dans `pg_description` et visible dans tous les outils
|
||||
d'admin BDD (DBeaver, DataGrip, pgAdmin), sans avoir à lire les annotations PHP.
|
||||
|
||||
**Format de la description** :
|
||||
- En français
|
||||
- ≤ 200 caractères
|
||||
- Sémantique du champ — contraintes / lien RG si pertinent
|
||||
- Pour les colonnes d'identifiant ou FK, mentionner la cible
|
||||
|
||||
Exemples :
|
||||
|
||||
```php
|
||||
// Migration : création d'une colonne avec son commentaire dans la même migration
|
||||
$this->addSql("ALTER TABLE client ADD COLUMN siren VARCHAR(9) DEFAULT NULL");
|
||||
$this->addSql("COMMENT ON COLUMN client.siren IS 'SIREN (9 chiffres) — identifiant legal entreprise. Unique parmi non-archives (RG-1.15).'");
|
||||
|
||||
// Cas FK : préciser la cible
|
||||
$this->addSql("COMMENT ON COLUMN client.legal_form_id IS 'Reference forme juridique (SARL, SAS, SA...) — FK -> legal_form.id, ON DELETE RESTRICT.'");
|
||||
|
||||
// Cas booléen : préciser le sens et la valeur par défaut
|
||||
$this->addSql("COMMENT ON COLUMN user.is_admin IS 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.'");
|
||||
|
||||
// Bonus : décrire la table elle-même
|
||||
$this->addSql("COMMENT ON TABLE client IS 'Repertoire clients (M1 Commercial) — entites archivables.'");
|
||||
```
|
||||
|
||||
### Helper Timestampable/Blamable
|
||||
|
||||
Les 4 colonnes `created_at`, `updated_at`, `created_by`, `updated_by` ajoutées par
|
||||
`TimestampableBlamableTrait` reçoivent une description **standardisée** via le helper centralisé pour
|
||||
éviter la duplication. Helper à créer ou appeler :
|
||||
|
||||
```php
|
||||
// Dans la migration, après avoir ajouté les 4 colonnes :
|
||||
$this->addStandardTimestampableBlamableComments($schema, 'client');
|
||||
```
|
||||
|
||||
L'implémentation du helper applique :
|
||||
- `created_at` : « Horodatage de creation de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). »
|
||||
- `updated_at` : « Horodatage de derniere modification de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). »
|
||||
- `created_by` : « ID de l'utilisateur ayant cree la ligne — null pour les creations hors HTTP (CLI, migration, fixture). FK -> user.id, ON DELETE SET NULL. »
|
||||
- `updated_by` : « ID de l'utilisateur ayant modifie la ligne en dernier — null pour les modifications hors HTTP. FK -> user.id, ON DELETE SET NULL. »
|
||||
|
||||
### Garde-fou architecture
|
||||
|
||||
`tests/Architecture/ColumnsHaveSqlCommentTest` parcourt `information_schema.columns` filtré sur le
|
||||
schéma `public` et échoue si **une seule colonne** n'a pas de `col_description`. Seules les tables
|
||||
système (`doctrine_migration_versions`) et la whitelist `EXCLUDED_TABLES` explicite (commentaire de
|
||||
justification + ticket Lesstime ouvert pour le retrofit) sont tolérées.
|
||||
|
||||
Conclusion : si tu crées une colonne sans poser son `COMMENT ON COLUMN`, `make test` casse en CI.
|
||||
@@ -56,7 +56,10 @@ jobs:
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.4'
|
||||
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium
|
||||
# gd requis par phpoffice/phpspreadsheet (export XLSX). Doit etre explicite :
|
||||
# sinon `composer install` echoue sur la verification de plateforme des que
|
||||
# le runner ne fournit pas l'extension par defaut (ext-gd manquante).
|
||||
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium, gd
|
||||
coverage: none
|
||||
tools: composer:v2
|
||||
|
||||
@@ -75,7 +78,7 @@ jobs:
|
||||
- name: Bootstrap test database
|
||||
# Aligne sur la cible `test-db-setup` du makefile : apres
|
||||
# `schema:update --force`, on RECREE manuellement l'index unique
|
||||
# partiel `uq_category_name_type_active` car Doctrine ORM ne sait
|
||||
# partiel `uq_category_name_active` car Doctrine ORM ne sait
|
||||
# pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE
|
||||
# deleted_at IS NULL) et `schema:update` les considere comme
|
||||
# orphelins et les DROP — collisions non detectees, tests d'unicite
|
||||
@@ -89,7 +92,7 @@ jobs:
|
||||
php bin/console app:apply-column-comments --env=test --no-interaction
|
||||
php bin/console doctrine:fixtures:load --env=test --no-interaction
|
||||
php bin/console app:sync-permissions --env=test --no-interaction
|
||||
php bin/console --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||
php bin/console --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_active ON category (LOWER(name)) WHERE deleted_at IS NULL"
|
||||
|
||||
- name: Run PHPUnit
|
||||
run: php -d memory_limit=512M vendor/bin/phpunit
|
||||
|
||||
@@ -141,7 +141,7 @@ Disponibles uniquement après `make db-reset` / `make fixtures` (état « avec s
|
||||
| `bob` | `bob` | ROLE_USER | — |
|
||||
| `bureau` | `demo` | ROLE_USER | clients : view + manage |
|
||||
| `compta` | `demo` | ROLE_USER | clients : view + accounting.view / manage |
|
||||
| `commerciale` | `demo` | ROLE_USER | clients : view + manage (Information obligatoire — RG-1.04) |
|
||||
| `commerciale` | `demo` | ROLE_USER | clients : view + manage |
|
||||
| `usine` | `demo` | ROLE_USER | aucun accès clients |
|
||||
|
||||
---
|
||||
|
||||
+4
-2
@@ -12,6 +12,7 @@
|
||||
"doctrine/doctrine-bundle": "^3.2",
|
||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||
"doctrine/orm": "^3.6",
|
||||
"dompdf/dompdf": "^3.0",
|
||||
"lexik/jwt-authentication-bundle": "^3.2",
|
||||
"nelmio/cors-bundle": "^2.6",
|
||||
"nyholm/psr7": "^1.8",
|
||||
@@ -24,6 +25,7 @@
|
||||
"symfony/expression-language": "8.0.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/http-client": "8.0.*",
|
||||
"symfony/intl": "8.0.*",
|
||||
"symfony/mime": "8.0.*",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
@@ -33,6 +35,7 @@
|
||||
"symfony/runtime": "8.0.*",
|
||||
"symfony/security-bundle": "8.0.*",
|
||||
"symfony/serializer": "8.0.*",
|
||||
"symfony/translation": "8.0.*",
|
||||
"symfony/twig-bundle": "8.0.*",
|
||||
"symfony/uid": "8.0.*",
|
||||
"symfony/validator": "8.0.*",
|
||||
@@ -94,7 +97,6 @@
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||
"friendsofphp/php-cs-fixer": "^3.94",
|
||||
"phpunit/phpunit": "^13.0",
|
||||
"symfony/browser-kit": "8.0.*",
|
||||
"symfony/http-client": "8.0.*"
|
||||
"symfony/browser-kit": "8.0.*"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+713
-175
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "aada2e60fd7563f1498b5505b37e3f4b",
|
||||
"content-hash": "224bae08ec63f217eabf5b2b611deaa0",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -2520,6 +2520,161 @@
|
||||
},
|
||||
"time": "2026-02-08T16:21:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/dompdf",
|
||||
"version": "v3.1.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/dompdf.git",
|
||||
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
|
||||
"reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dompdf/php-font-lib": "^1.0.0",
|
||||
"dompdf/php-svg-lib": "^1.0.0",
|
||||
"ext-dom": "*",
|
||||
"ext-mbstring": "*",
|
||||
"masterminds/html5": "^2.0",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-gd": "*",
|
||||
"ext-json": "*",
|
||||
"ext-zip": "*",
|
||||
"mockery/mockery": "^1.3",
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
|
||||
"squizlabs/php_codesniffer": "^3.5",
|
||||
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gd": "Needed to process images",
|
||||
"ext-gmagick": "Improves image processing performance",
|
||||
"ext-imagick": "Improves image processing performance",
|
||||
"ext-zlib": "Needed for pdf stream compression"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Dompdf\\": "src/"
|
||||
},
|
||||
"classmap": [
|
||||
"lib/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The Dompdf Community",
|
||||
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
|
||||
"homepage": "https://github.com/dompdf/dompdf",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/dompdf/issues",
|
||||
"source": "https://github.com/dompdf/dompdf/tree/v3.1.5"
|
||||
},
|
||||
"time": "2026-03-03T13:54:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/php-font-lib",
|
||||
"version": "1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/php-font-lib.git",
|
||||
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
|
||||
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"FontLib\\": "src/FontLib"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The FontLib Community",
|
||||
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "A library to read, parse, export and make subsets of different types of font files.",
|
||||
"homepage": "https://github.com/dompdf/php-font-lib",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/php-font-lib/issues",
|
||||
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
|
||||
},
|
||||
"time": "2026-01-20T14:10:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/php-svg-lib",
|
||||
"version": "1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/php-svg-lib.git",
|
||||
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
|
||||
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0",
|
||||
"sabberworm/php-css-parser": "^8.4 || ^9.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Svg\\": "src/Svg"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-3.0-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The SvgLib Community",
|
||||
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "A library to read, parse and export to PDF SVG files.",
|
||||
"homepage": "https://github.com/dompdf/php-svg-lib",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/php-svg-lib/issues",
|
||||
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
|
||||
},
|
||||
"time": "2026-01-02T16:01:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lcobucci/jwt",
|
||||
"version": "5.6.0",
|
||||
@@ -2894,6 +3049,73 @@
|
||||
},
|
||||
"time": "2022-12-02T22:17:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "masterminds/html5",
|
||||
"version": "2.10.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Masterminds/html5-php.git",
|
||||
"reference": "fd5018f6815fff903946d0564977b44ce8010e29"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29",
|
||||
"reference": "fd5018f6815fff903946d0564977b44ce8010e29",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"php": ">=5.3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.7-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Masterminds\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Matt Butcher",
|
||||
"email": "technosophos@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Matt Farina",
|
||||
"email": "matt@mattfarina.com"
|
||||
},
|
||||
{
|
||||
"name": "Asmir Mustafic",
|
||||
"email": "goetas@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "An HTML5 parser and serializer.",
|
||||
"homepage": "http://masterminds.github.io/html5-php",
|
||||
"keywords": [
|
||||
"HTML5",
|
||||
"dom",
|
||||
"html",
|
||||
"parser",
|
||||
"querypath",
|
||||
"serializer",
|
||||
"xml"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Masterminds/html5-php/issues",
|
||||
"source": "https://github.com/Masterminds/html5-php/tree/2.10.1"
|
||||
},
|
||||
"time": "2026-06-23T18:43:15+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.10.0",
|
||||
@@ -3937,6 +4159,86 @@
|
||||
},
|
||||
"time": "2021-10-29T13:26:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabberworm/php-css-parser",
|
||||
"version": "v9.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
|
||||
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
|
||||
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-iconv": "*",
|
||||
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
|
||||
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-parallel-lint/php-parallel-lint": "1.4.0",
|
||||
"phpstan/extension-installer": "1.4.3",
|
||||
"phpstan/phpstan": "1.12.33 || 2.2.2",
|
||||
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.16",
|
||||
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11",
|
||||
"phpunit/phpunit": "8.5.52",
|
||||
"rawr/phpunit-data-provider": "3.3.1",
|
||||
"rector/rector": "1.2.10 || 2.4.6",
|
||||
"rector/type-perfect": "1.0.0 || 2.1.3",
|
||||
"squizlabs/php_codesniffer": "4.0.1",
|
||||
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-mbstring": "for parsing UTF-8 CSS"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "9.5.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/Rule/Rule.php",
|
||||
"src/RuleSet/RuleContainer.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sabberworm\\CSS\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Raphael Schweikert"
|
||||
},
|
||||
{
|
||||
"name": "Oliver Klee",
|
||||
"email": "github@oliverklee.de"
|
||||
},
|
||||
{
|
||||
"name": "Jake Hotson",
|
||||
"email": "jake.github@qzdesign.co.uk"
|
||||
}
|
||||
],
|
||||
"description": "Parser for CSS Files written in PHP",
|
||||
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
|
||||
"keywords": [
|
||||
"css",
|
||||
"parser",
|
||||
"stylesheet"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
|
||||
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0"
|
||||
},
|
||||
"time": "2026-06-18T15:10:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/asset",
|
||||
"version": "v8.0.8",
|
||||
@@ -5412,6 +5714,180 @@
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v8.0.13",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "c7f40f9103233630167c25c9a4570acf805fdade"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/c7f40f9103233630167c25c9a4570acf805fdade",
|
||||
"reference": "c7f40f9103233630167c25c9a4570acf805fdade",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"amphp/amp": "<3",
|
||||
"php-http/discovery": "<1.15"
|
||||
},
|
||||
"provide": {
|
||||
"php-http/async-client-implementation": "*",
|
||||
"php-http/client-implementation": "*",
|
||||
"psr/http-client-implementation": "1.0",
|
||||
"symfony/http-client-implementation": "3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"amphp/http-client": "^5.3.2",
|
||||
"amphp/http-tunnel": "^2.0",
|
||||
"guzzlehttp/promises": "^1.4|^2.0",
|
||||
"nyholm/psr7": "^1.0",
|
||||
"php-http/httplug": "^1.0|^2.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/cache": "^7.4|^8.0",
|
||||
"symfony/dependency-injection": "^7.4|^8.0",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/messenger": "^7.4|^8.0",
|
||||
"symfony/process": "^7.4|^8.0",
|
||||
"symfony/rate-limiter": "^7.4|^8.0",
|
||||
"symfony/stopwatch": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client/tree/v8.0.13"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-24T09:58:02+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client-contracts",
|
||||
"version": "v3.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client-contracts.git",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/contracts",
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.6-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Contracts\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Test/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Generic abstractions related to HTTP clients",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"abstractions",
|
||||
"contracts",
|
||||
"decoupling",
|
||||
"interfaces",
|
||||
"interoperability",
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-04-29T11:18:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-foundation",
|
||||
"version": "v8.0.8",
|
||||
@@ -7657,6 +8133,99 @@
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/translation",
|
||||
"version": "v8.0.10",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/translation.git",
|
||||
"reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67",
|
||||
"reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"symfony/polyfill-mbstring": "^1.0",
|
||||
"symfony/translation-contracts": "^3.6.1"
|
||||
},
|
||||
"conflict": {
|
||||
"nikic/php-parser": "<5.0",
|
||||
"symfony/http-client-contracts": "<2.5",
|
||||
"symfony/service-contracts": "<2.5"
|
||||
},
|
||||
"provide": {
|
||||
"symfony/translation-implementation": "2.3|3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"nikic/php-parser": "^5.0",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/config": "^7.4|^8.0",
|
||||
"symfony/console": "^7.4|^8.0",
|
||||
"symfony/dependency-injection": "^7.4|^8.0",
|
||||
"symfony/finder": "^7.4|^8.0",
|
||||
"symfony/http-client-contracts": "^2.5|^3.0",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/intl": "^7.4|^8.0",
|
||||
"symfony/polyfill-intl-icu": "^1.21",
|
||||
"symfony/routing": "^7.4|^8.0",
|
||||
"symfony/service-contracts": "^2.5|^3",
|
||||
"symfony/yaml": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"Resources/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Translation\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides tools to internationalize your application",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/translation/tree/v8.0.10"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-06T11:30:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/translation-contracts",
|
||||
"version": "v3.6.1",
|
||||
@@ -8512,6 +9081,149 @@
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "thecodingmachine/safe",
|
||||
"version": "v3.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thecodingmachine/safe.git",
|
||||
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
|
||||
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-parallel-lint/php-parallel-lint": "^1.4",
|
||||
"phpstan/phpstan": "^2",
|
||||
"phpunit/phpunit": "^10",
|
||||
"squizlabs/php_codesniffer": "^3.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"lib/special_cases.php",
|
||||
"generated/apache.php",
|
||||
"generated/apcu.php",
|
||||
"generated/array.php",
|
||||
"generated/bzip2.php",
|
||||
"generated/calendar.php",
|
||||
"generated/classobj.php",
|
||||
"generated/com.php",
|
||||
"generated/cubrid.php",
|
||||
"generated/curl.php",
|
||||
"generated/datetime.php",
|
||||
"generated/dir.php",
|
||||
"generated/eio.php",
|
||||
"generated/errorfunc.php",
|
||||
"generated/exec.php",
|
||||
"generated/fileinfo.php",
|
||||
"generated/filesystem.php",
|
||||
"generated/filter.php",
|
||||
"generated/fpm.php",
|
||||
"generated/ftp.php",
|
||||
"generated/funchand.php",
|
||||
"generated/gettext.php",
|
||||
"generated/gmp.php",
|
||||
"generated/gnupg.php",
|
||||
"generated/hash.php",
|
||||
"generated/ibase.php",
|
||||
"generated/ibmDb2.php",
|
||||
"generated/iconv.php",
|
||||
"generated/image.php",
|
||||
"generated/imap.php",
|
||||
"generated/info.php",
|
||||
"generated/inotify.php",
|
||||
"generated/json.php",
|
||||
"generated/ldap.php",
|
||||
"generated/libxml.php",
|
||||
"generated/lzf.php",
|
||||
"generated/mailparse.php",
|
||||
"generated/mbstring.php",
|
||||
"generated/misc.php",
|
||||
"generated/mysql.php",
|
||||
"generated/mysqli.php",
|
||||
"generated/network.php",
|
||||
"generated/oci8.php",
|
||||
"generated/opcache.php",
|
||||
"generated/openssl.php",
|
||||
"generated/outcontrol.php",
|
||||
"generated/pcntl.php",
|
||||
"generated/pcre.php",
|
||||
"generated/pgsql.php",
|
||||
"generated/posix.php",
|
||||
"generated/ps.php",
|
||||
"generated/pspell.php",
|
||||
"generated/readline.php",
|
||||
"generated/rnp.php",
|
||||
"generated/rpminfo.php",
|
||||
"generated/rrd.php",
|
||||
"generated/sem.php",
|
||||
"generated/session.php",
|
||||
"generated/shmop.php",
|
||||
"generated/sockets.php",
|
||||
"generated/sodium.php",
|
||||
"generated/solr.php",
|
||||
"generated/spl.php",
|
||||
"generated/sqlsrv.php",
|
||||
"generated/ssdeep.php",
|
||||
"generated/ssh2.php",
|
||||
"generated/stream.php",
|
||||
"generated/strings.php",
|
||||
"generated/swoole.php",
|
||||
"generated/uodbc.php",
|
||||
"generated/uopz.php",
|
||||
"generated/url.php",
|
||||
"generated/var.php",
|
||||
"generated/xdiff.php",
|
||||
"generated/xml.php",
|
||||
"generated/xmlrpc.php",
|
||||
"generated/yaml.php",
|
||||
"generated/yaz.php",
|
||||
"generated/zip.php",
|
||||
"generated/zlib.php"
|
||||
],
|
||||
"classmap": [
|
||||
"lib/DateTime.php",
|
||||
"lib/DateTimeImmutable.php",
|
||||
"lib/Exceptions/",
|
||||
"generated/Exceptions/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
|
||||
"support": {
|
||||
"issues": "https://github.com/thecodingmachine/safe/issues",
|
||||
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/OskarStark",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/shish",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/silasjoisten",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/staabm",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-04T18:08:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "twig/twig",
|
||||
"version": "v3.24.0",
|
||||
@@ -11692,180 +12404,6 @@
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v8.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e",
|
||||
"reference": "356e43d6994ae9d7761fd404d40f78691deabe0e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"amphp/amp": "<3",
|
||||
"php-http/discovery": "<1.15"
|
||||
},
|
||||
"provide": {
|
||||
"php-http/async-client-implementation": "*",
|
||||
"php-http/client-implementation": "*",
|
||||
"psr/http-client-implementation": "1.0",
|
||||
"symfony/http-client-implementation": "3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"amphp/http-client": "^5.3.2",
|
||||
"amphp/http-tunnel": "^2.0",
|
||||
"guzzlehttp/promises": "^1.4|^2.0",
|
||||
"nyholm/psr7": "^1.0",
|
||||
"php-http/httplug": "^1.0|^2.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/cache": "^7.4|^8.0",
|
||||
"symfony/dependency-injection": "^7.4|^8.0",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/messenger": "^7.4|^8.0",
|
||||
"symfony/process": "^7.4|^8.0",
|
||||
"symfony/rate-limiter": "^7.4|^8.0",
|
||||
"symfony/stopwatch": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client/tree/v8.0.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client-contracts",
|
||||
"version": "v3.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client-contracts.git",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/contracts",
|
||||
"name": "symfony/contracts"
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.6-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Contracts\\HttpClient\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Test/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Generic abstractions related to HTTP clients",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"abstractions",
|
||||
"contracts",
|
||||
"decoupling",
|
||||
"interfaces",
|
||||
"interoperability",
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-04-29T11:18:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/process",
|
||||
"version": "v8.0.8",
|
||||
|
||||
@@ -4,11 +4,17 @@ declare(strict_types=1);
|
||||
use App\Module\Catalog\CatalogModule;
|
||||
use App\Module\Commercial\CommercialModule;
|
||||
use App\Module\Core\CoreModule;
|
||||
use App\Module\Logistique\LogistiqueModule;
|
||||
use App\Module\Sites\SitesModule;
|
||||
use App\Module\Technique\TechniqueModule;
|
||||
use App\Module\Transport\TransportModule;
|
||||
|
||||
return [
|
||||
CoreModule::class,
|
||||
CommercialModule::class,
|
||||
SitesModule::class,
|
||||
CatalogModule::class,
|
||||
TechniqueModule::class,
|
||||
TransportModule::class,
|
||||
LogistiqueModule::class,
|
||||
];
|
||||
|
||||
@@ -12,6 +12,9 @@ api_platform:
|
||||
# Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource]
|
||||
# en dehors de Domain/Entity : AuditLogResource, etc.
|
||||
- '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource'
|
||||
# Entites techniques partagees portant un #[ApiResource]
|
||||
# (UploadedDocument — infra upload generique ERP-154).
|
||||
- '%kernel.project_dir%/src/Shared/Domain/Entity'
|
||||
formats:
|
||||
jsonld: ['application/ld+json']
|
||||
json: ['application/json']
|
||||
|
||||
@@ -8,16 +8,34 @@ doctrine:
|
||||
default:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
# Exclut `audit_log` de toute operation de comparaison de schema
|
||||
# (doctrine:schema:update, schema:validate, diff de migrations...).
|
||||
# Cette table n'a volontairement aucune entite mappee : elle est
|
||||
# append-only via DBAL brut (AuditLogWriter) pour eviter la
|
||||
# recursion du listener Doctrine. Sans ce filtre, schema:update
|
||||
# la considere comme "orpheline" et genere un `DROP TABLE
|
||||
# audit_log` qui casse la base de test apres chaque
|
||||
# `make test-db-setup`. La creation / suppression de la table
|
||||
# reste pilotee par les migrations (cf. Version20260420202749).
|
||||
schema_filter: '~^(?!audit_log$).+~'
|
||||
# Exclut certaines tables de toute operation de comparaison de
|
||||
# schema (doctrine:schema:update, schema:validate, diff de
|
||||
# migrations...). Ces tables n'ont volontairement aucune entite
|
||||
# mappee :
|
||||
# - `audit_log` : append-only via DBAL brut (AuditLogWriter) pour
|
||||
# eviter la recursion du listener Doctrine.
|
||||
# - `qualimat_sync_log` : journal de synchro transporteurs
|
||||
# QUALIMAT, ecrit en DBAL brut par `app:qualimat:sync`, hors ORM.
|
||||
# NB : `qualimat_carrier` n'est PLUS filtree depuis M4 (ERP-155) :
|
||||
# elle est desormais mappee en LECTURE SEULE par l'entite
|
||||
# App\Module\Transport\Domain\Entity\QualimatCarrier (cible de la
|
||||
# FK editable carrier.qualimat_carrier_id). Son mapping reproduit
|
||||
# a l'identique le DDL de la migration ERP-39 (unique siret, index
|
||||
# is_active, TIMESTAMP(6)) -> schema:update reste un no-op.
|
||||
# - `idtf_product` / `idtf_sync_log` : referentiel codes IDTF
|
||||
# synchronise en DBAL brut par `app:idtf:sync`, hors ORM.
|
||||
# - `weighing_ticket_counter` / `weighbridge_dsd_counter` : compteurs
|
||||
# par site (numero de ticket de pesee RG-5.02 / DSD du pont RG-5.04,
|
||||
# M5 Logistique), incrementes en DBAL brut sous verrou `FOR UPDATE`
|
||||
# par l'allocateur — jamais mappes en ORM (cf. spec M5 § 2.5 / § 2.7).
|
||||
# Sans ce filtre, schema:update les considere comme "orphelines" et
|
||||
# genere un `DROP TABLE` qui casse la base de test apres chaque
|
||||
# `make test-db-setup` (la migration les a creees, schema:update les
|
||||
# supprime juste apres). Creation / suppression restent pilotees par
|
||||
# les migrations (audit_log : Version20260420202749 ; qualimat :
|
||||
# Version20260612150000 ; idtf : Version20260612160000 ; 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:
|
||||
@@ -41,7 +59,26 @@ doctrine:
|
||||
# Permet au module Commercial de referencer une Category via le contrat
|
||||
# Shared sans importer la classe concrete du module Catalog (regle n°1).
|
||||
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
|
||||
# Cibles des ManyToOne de CarrierPrice (M4 Transport, onglet Prix) :
|
||||
# permet au module Transport de referencer Client / Supplier et leurs
|
||||
# adresses (M1/M2 Commercial) via des contrats Shared sans importer les
|
||||
# classes concretes (regle n°1). L'embed JSON passe par les read-groups
|
||||
# des entites concretes (client:read / supplier:read / ...).
|
||||
App\Shared\Domain\Contract\ClientInterface: App\Module\Commercial\Domain\Entity\Client
|
||||
App\Shared\Domain\Contract\ClientAddressInterface: App\Module\Commercial\Domain\Entity\ClientAddress
|
||||
App\Shared\Domain\Contract\SupplierInterface: App\Module\Commercial\Domain\Entity\Supplier
|
||||
App\Shared\Domain\Contract\SupplierAddressInterface: App\Module\Commercial\Domain\Entity\SupplierAddress
|
||||
mappings:
|
||||
# Mapping des entites techniques partagees (src/Shared/Domain/Entity).
|
||||
# Premier occupant : UploadedDocument (infra upload generique ERP-154).
|
||||
# Necessaire car les entites Shared ne sont pas couvertes par
|
||||
# l'auto_mapping (qui ne cible que les bundles).
|
||||
Shared:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Shared/Domain/Entity'
|
||||
prefix: 'App\Shared\Domain\Entity'
|
||||
alias: Shared
|
||||
Core:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
@@ -80,6 +117,38 @@ doctrine:
|
||||
dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity'
|
||||
prefix: 'App\Module\Commercial\Domain\Entity'
|
||||
alias: Commercial
|
||||
# Mapping inconditionnel du module Technique (meme logique que Commercial) :
|
||||
# les tables prestataires (provider + sous-collections + jointures M2M)
|
||||
# creees par la migration M3 (Version20260612100000) doivent etre connues
|
||||
# de l'ORM. L'activation fonctionnelle passe par config/modules.php.
|
||||
Technique:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Technique/Domain/Entity'
|
||||
prefix: 'App\Module\Technique\Domain\Entity'
|
||||
alias: Technique
|
||||
# Mapping inconditionnel du module Transport (meme logique que Technique) :
|
||||
# les tables transporteurs (carrier + sous-collections) creees par la
|
||||
# migration M4 (Version20260615150000) et le mapping lecture-seule de
|
||||
# qualimat_carrier (referentiel ERP-39) doivent etre connus de l'ORM.
|
||||
# L'activation fonctionnelle passe par config/modules.php.
|
||||
Transport:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Transport/Domain/Entity'
|
||||
prefix: 'App\Module\Transport\Domain\Entity'
|
||||
alias: Transport
|
||||
# Mapping inconditionnel du module Logistique (meme logique que Transport) :
|
||||
# la table weighing_ticket (tickets de pesee M5) creee par la migration
|
||||
# Version20260617150000 doit etre connue de l'ORM, sinon schema:update la
|
||||
# drope sur la base de test. L'activation fonctionnelle passe par
|
||||
# config/modules.php.
|
||||
Logistique:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Logistique/Domain/Entity'
|
||||
prefix: 'App\Module\Logistique\Domain\Entity'
|
||||
alias: Logistique
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
|
||||
@@ -2,4 +2,5 @@ doctrine_migrations:
|
||||
migrations_paths:
|
||||
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||
'App\Module\Core\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Core/Infrastructure/Doctrine/Migrations'
|
||||
'App\Module\Transport\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Transport/Infrastructure/Doctrine/Migrations'
|
||||
enable_profiler: false
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Active le composant HTTP Client (symfony/http-client) et enregistre
|
||||
# l'autowiring de HttpClientInterface. Utilise par les commandes de
|
||||
# synchronisation de referentiels externes (QUALIMAT, IDTF...).
|
||||
#
|
||||
# User-Agent navigateur neutre : les sources (qualimat.org sous WordPress/WAF,
|
||||
# icrt-idtf.com) filtrent souvent les UA de bibliotheque/vides ; un UA de type
|
||||
# navigateur evite les blocages anti-bot sans reveler l'application.
|
||||
framework:
|
||||
http_client:
|
||||
default_options:
|
||||
timeout: 30
|
||||
headers:
|
||||
User-Agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
||||
@@ -0,0 +1,12 @@
|
||||
doctrine:
|
||||
dbal:
|
||||
connections:
|
||||
# Force le profiling DBAL en environnement de test independamment de
|
||||
# APP_DEBUG. Sans cela, la CI tourne en APP_DEBUG=0 (prod-like) et le
|
||||
# service `doctrine.debug_data_holder` n'est pas enregistre : le test
|
||||
# anti-N+1 (SupplierListTest::testListQueryCountDoesNotGrowWithRowCount)
|
||||
# qui compte les requetes via ce holder echoue alors en CI alors qu'il
|
||||
# passe en local (APP_DEBUG=1). Activer le profiling ici garde le test
|
||||
# actif precisement la ou il compte (CI), sans impacter la prod.
|
||||
default:
|
||||
profiling: true
|
||||
@@ -0,0 +1,12 @@
|
||||
framework:
|
||||
# Locale par defaut FR (ERP-107) : les messages natifs des contraintes
|
||||
# Symfony (Email, NotBlank, Length, Iban, Bic...) sont alors servis en
|
||||
# francais via validators.fr.xlf. C'est le FILET ; les contraintes metier
|
||||
# portent en plus un `message:` FR explicite, teste par
|
||||
# tests/Architecture/EntityConstraintsHaveFrenchMessageTest.
|
||||
default_locale: fr
|
||||
translator:
|
||||
default_path: '%kernel.project_dir%/translations'
|
||||
fallbacks:
|
||||
- fr
|
||||
providers:
|
||||
@@ -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
|
||||
|
||||
+83
-20
@@ -38,6 +38,66 @@ declare(strict_types=1);
|
||||
*/
|
||||
|
||||
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.
|
||||
// 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).
|
||||
[
|
||||
'label' => 'sidebar.commercial.section',
|
||||
'icon' => 'mdi:account-arrow-left-outline',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.commercial.suppliers',
|
||||
'to' => '/suppliers',
|
||||
'icon' => 'mdi:account-arrow-left-outline',
|
||||
'module' => 'commercial',
|
||||
'permission' => 'commercial.suppliers.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.commercial.clients',
|
||||
'to' => '/clients',
|
||||
'icon' => 'mdi:account-group-outline',
|
||||
'module' => 'commercial',
|
||||
'permission' => 'commercial.clients.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Technique" (M3, ERP-138) : pole distinct du Commercial, porte le
|
||||
// repertoire prestataires. L'item est gate par `technique.providers.view` ;
|
||||
// la section disparait automatiquement (SidebarProvider) si le module
|
||||
// `technique` est desactive ou si l'user n'a pas la permission.
|
||||
[
|
||||
'label' => 'sidebar.technique.section',
|
||||
'icon' => 'mdi:account-convert-outline',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.technique.providers',
|
||||
'to' => '/providers',
|
||||
'icon' => 'mdi:account-wrench-outline',
|
||||
'module' => 'technique',
|
||||
'permission' => 'technique.providers.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Administration" : regroupe toutes les pages de configuration
|
||||
// applicative (RBAC, users, sites, audit log).
|
||||
//
|
||||
@@ -60,8 +120,30 @@ return [
|
||||
// individuelles"), ajouter : 'permission' => 'core.admin.access'.
|
||||
[
|
||||
'label' => 'sidebar.administration.section',
|
||||
'icon' => 'mdi:cog-outline',
|
||||
'icon' => 'mdi:file-settings-cog-outline',
|
||||
'items' => [
|
||||
// Transport — Repertoire transporteurs (M4, ERP-164). Rattache a
|
||||
// l'Administration (premier item) plutot qu'a une section dediee :
|
||||
// referentiel global de configuration applicative, sans cloisonnement
|
||||
// par site. Reste gate par sa propre permission `transport.carriers.view`
|
||||
// (Admin / Bureau / Commerciale) et son module owner `transport`.
|
||||
[
|
||||
'label' => 'sidebar.transport.carriers',
|
||||
'to' => '/carriers',
|
||||
'icon' => 'mdi:truck-outline',
|
||||
'module' => 'transport',
|
||||
'permission' => 'transport.carriers.view',
|
||||
],
|
||||
// Catalogue produit (M6, ERP-197). Place juste sous le repertoire
|
||||
// transporteurs (DECISION Matthieu 24/06). Admin-only : gate par
|
||||
// `catalog.products.view` et son module owner `catalog`.
|
||||
[
|
||||
'label' => 'sidebar.catalog.products',
|
||||
'to' => '/admin/products',
|
||||
'icon' => 'mdi:package-variant-closed',
|
||||
'module' => 'catalog',
|
||||
'permission' => 'catalog.products.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.core.roles',
|
||||
'to' => '/admin/roles',
|
||||
@@ -99,25 +181,6 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.commercial.section',
|
||||
'icon' => 'mdi:account-arrow-left-outline',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.commercial.clients',
|
||||
'to' => '/clients',
|
||||
'icon' => 'mdi:account-group-outline',
|
||||
'module' => 'commercial',
|
||||
'permission' => 'commercial.clients.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.commercial.suppliers',
|
||||
'to' => '/suppliers',
|
||||
'icon' => 'mdi:account-arrow-left-outline',
|
||||
'module' => 'commercial',
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Mon compte" : espace personnel. Accessible a tout user authentifie
|
||||
// (aucune permission RBAC requise, tous les items restent dans `core` pour
|
||||
// rester toujours presents meme quand les modules metier sont desactives).
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.76'
|
||||
app.version: '0.1.154'
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# Validation « tous les blocs » sur les onglets à blocs dynamiques (Client M1)
|
||||
|
||||
> Date : 2026-06-04 · Module : Commercial (M1 Clients) · Tickets liés : ERP-101 / ERP-107
|
||||
> Écrans : `clients/new.vue`, `clients/[id]/edit.vue` · Onglets concernés : Contacts, Adresses, RIB
|
||||
|
||||
## 1. Problème
|
||||
|
||||
À la soumission des onglets à **blocs d'ajout dynamiques** (Contacts / Adresses / RIB), la validation
|
||||
par champ ne s'affiche pas correctement. Deux causes **distinctes et cumulées** :
|
||||
|
||||
### Cause A — 500 back qui court-circuite la validation (cause racine)
|
||||
|
||||
Les opérations `Post` des sous-ressources sont déclarées ainsi :
|
||||
|
||||
```php
|
||||
new Post(
|
||||
uriTemplate: '/clients/{clientId}/contacts',
|
||||
uriVariables: ['clientId' => new Link(fromClass: Client::class, toProperty: 'client')],
|
||||
processor: ClientContactProcessor::class,
|
||||
)
|
||||
```
|
||||
|
||||
Au stade « read » du POST, API Platform résout `clientId` via `LinksHandlerTrait` (branche `toProperty`,
|
||||
`vendor/api-platform/doctrine-orm/State/LinksHandlerTrait.php:134-141`). La requête générée porte sur
|
||||
l'entité **enfant** :
|
||||
|
||||
```sql
|
||||
SELECT o FROM ClientContact o INNER JOIN o.client c WHERE c.id = :clientId
|
||||
```
|
||||
|
||||
exécutée via `ItemProvider::provide` → `getOneOrNullResult()`
|
||||
(`vendor/api-platform/doctrine-orm/State/ItemProvider.php:89`). Donc :
|
||||
|
||||
| Nb d'enfants du client | Lignes retournées | Résultat |
|
||||
|---|---|---|
|
||||
| 0 | 0 | `null` → OK (cas du test CI actuel) |
|
||||
| 1 | 1 | OK |
|
||||
| **≥ 2** | **≥ 2** | **`NonUniqueResultException` → HTTP 500** |
|
||||
|
||||
Conséquence : un client à ≥2 contacts (resp. adresses, RIB) ne peut plus en recevoir un nouveau.
|
||||
La 500 survient **avant** la déserialisation/validation → aucune 422 n'est produite → `mapRowError`
|
||||
(qui ne mappe que les 422) retombe sur un toast générique.
|
||||
|
||||
Les **3** sous-ressources ont strictement la même config → même bug latent (contacts est juste le
|
||||
premier à sauter car les clients de démo ont 3 contacts).
|
||||
|
||||
### Cause B — la boucle front s'arrête au premier bloc en erreur
|
||||
|
||||
`submitContacts` / `submitAddresses` / boucle RIB de `submitAccounting` (dans `new.vue` ET `edit.vue`)
|
||||
font `return` dans le `catch` du premier bloc en échec :
|
||||
|
||||
```js
|
||||
catch (error) {
|
||||
if (!mapRowError(error, contactErrors, index)) { toast(...) }
|
||||
return // ← stoppe : les blocs suivants ne sont jamais validés ni affichés
|
||||
}
|
||||
```
|
||||
|
||||
→ même une fois le 500 corrigé, seules les erreurs du **premier** bloc fautif s'afficheraient.
|
||||
|
||||
## 2. Objectif
|
||||
|
||||
À la validation d'un onglet collection, **tenter tous les blocs** et **afficher l'erreur inline sous
|
||||
chaque champ fautif, pour chaque bloc**, en un seul aller-retour de soumission. Pas de toast récapitulatif
|
||||
(décision : inline seul, cohérent ERP-101). Pas de toast succès tant qu'au moins un bloc reste en erreur.
|
||||
|
||||
Hors périmètre : le workflow incrémental (créer le client, puis débloquer les onglets) reste inchangé ;
|
||||
les onglets scalaires (Principal / Information / Comptabilité-scalaires) fonctionnent déjà et ne sont pas
|
||||
touchés.
|
||||
|
||||
## 3. Conception
|
||||
|
||||
### 3.1 Back — supprimer le read cassé du POST (cause racine)
|
||||
|
||||
Sur les opérations `Post` de `ClientContact`, `ClientAddress`, `ClientRib` :
|
||||
|
||||
- Ajouter **`read: false`**. Le stade « read » est inutile : le `*Processor::linkParent` rattache déjà le
|
||||
parent manuellement via `$em->getRepository(Client::class)->find($clientId)`. Pattern déjà employé dans
|
||||
le projet (`Sites/.../CurrentSiteResource.php`).
|
||||
- Durcir les 3 `linkParent` : si `find($clientId)` renvoie `null`, lever
|
||||
`Symfony\Component\HttpKernel\Exception\NotFoundHttpException` (préserve le **404** sur parent
|
||||
inexistant — sans le read, on régresserait sinon en 500 au persist sur `client_id NOT NULL`).
|
||||
|
||||
Effet : plus de `getOneOrNullResult` foireux → déserialisation + validation Symfony s'exécutent → **422
|
||||
propre par champ** avec `violations[].propertyPath` (déjà garanti par ERP-107 : messages FR explicites).
|
||||
|
||||
Aucune autre modification (security, normalizationContext, processor restant) n'est nécessaire.
|
||||
|
||||
### 3.2 Front — collecter les erreurs de tous les blocs
|
||||
|
||||
Dans `submitContacts`, `submitAddresses`, et la boucle RIB de `submitAccounting`, **dans `new.vue` ET
|
||||
`edit.vue`** :
|
||||
|
||||
- Conserver la réinitialisation du tableau d'erreurs en début de submit (`xxxErrors.value = []`).
|
||||
- Introduire un drapeau local `hasError`. Dans le `catch`, remplacer `return` par
|
||||
`hasError = true; continue` → la boucle tente/valide **tous** les blocs ; chaque 422 se mappe sur
|
||||
`xxxErrors[index]` via `mapRowError` (mécanique existante, inchangée).
|
||||
- Après la boucle : si `hasError` → **ne pas** appeler `completeTab(...)`, **pas** de toast succès. Sinon
|
||||
→ comportement actuel (`completeTab` + toast succès).
|
||||
- Les blocs déjà créés (id non-null) repassent en `PATCH` au resubmit → idempotent, pas de doublon.
|
||||
- Awaits **séquentiels** conservés (volume faible, ordre des blocs préservé, pas de course).
|
||||
|
||||
Le binding inline est déjà en place côté template (`:errors="contactErrors[index]"` /
|
||||
`:error="ribErrors[index]?.iban"` …). Aucun changement de composant `Malio*` requis.
|
||||
|
||||
### 3.3 Réutilisation / isolation
|
||||
|
||||
Le bloc « boucle de soumission d'une collection avec collecte d'erreurs par index » est dupliqué 3× × 2
|
||||
pages. Pour rester testable et DRY, extraire un helper de soumission de collection (ex.
|
||||
`submitCollection(rows, { buildBody, post, patch, errors })` retournant `{ hasError }`) consommé par les
|
||||
6 sites d'appel. À acter dans le plan d'implémentation (option : garder inline si l'extraction dégrade la
|
||||
lisibilité — décision lors du plan).
|
||||
|
||||
## 4. Tests
|
||||
|
||||
### Back (TDD — échouent d'abord)
|
||||
|
||||
Dans `tests/Module/Commercial/Api/ClientSubResourceApiTest` :
|
||||
|
||||
- `testPostContactToClientWithTwoExistingContactsReturns201` : seed un client + 2 contacts, POST un 3ᵉ →
|
||||
attendu **201** (rouge aujourd'hui : 500).
|
||||
- `testPostContactInvalidEmailOnClientWithExistingContactsReturns422` : même seed, POST email invalide →
|
||||
**422** avec `propertyPath=email` et message FR (vérifie que la validation est bien atteinte).
|
||||
- Variantes germes pour adresses et RIB (au moins une chacune) pour verrouiller les 3 sous-ressources.
|
||||
|
||||
Pré-requis : helper de seed de contacts/adresses/RIB dans `AbstractCommercialApiTestCase` (ajouter si
|
||||
absent).
|
||||
|
||||
### Front (Vitest)
|
||||
|
||||
- Si helper `submitCollection` extrait : test unitaire « 3 blocs, le 2ᵉ renvoie 422 → les erreurs du 2ᵉ
|
||||
sont mappées, les blocs 1 et 3 sont tentés, `hasError = true`, tab non complété ».
|
||||
- Sinon : test de composant sur `ClientContactBlock` + page, vérifiant l'affichage inline multi-blocs.
|
||||
|
||||
### Vérifications finales
|
||||
|
||||
`make test` + `make php-cs-fixer-allow-risky` (back), `make nuxt-test` (front). Golden path manuel :
|
||||
client à 3 contacts, ajouter un 4ᵉ avec email invalide → 422 inline sous l'email du bon bloc, pas de 500.
|
||||
|
||||
## 5. Impact / risques
|
||||
|
||||
- API contract : POST sous-ressource passe de 500→201/422 (correction) ; 404 préservé sur parent
|
||||
inexistant. Pas de changement de payload ni de réponse de succès.
|
||||
- Le test fonctionnel CI actuel (POST sur client à 0 contact) reste vert.
|
||||
- Régression possible si un consommateur dépendait du read implicite du parent au POST : aucun identifié
|
||||
(les 3 processors gèrent déjà le rattachement manuellement).
|
||||
@@ -0,0 +1,633 @@
|
||||
# Validation « tous les blocs » — onglets à blocs dynamiques (Client M1) — Plan d'implémentation
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Permettre la validation 422 par champ sur TOUS les blocs des onglets Contacts / Adresses / RIB d'un client (création + édition), en supprimant la 500 `NonUniqueResultException` qui les bloque dès ≥2 enfants et en ne stoppant plus la boucle front au premier bloc en erreur.
|
||||
|
||||
**Architecture:** Côté back, on retire le stade « read » inutile du POST des 3 sous-ressources (`read: false`) — le parent est déjà rattaché manuellement par le processor — et on durcit ce rattachement (404 si parent absent). Côté front, on factorise la boucle de soumission de collection dans `useClientFormErrors().submitRows(...)` qui tente tous les blocs et collecte les erreurs par index, puis on branche les 6 sites d'appel (`new.vue` + `edit.vue` × contacts/adresses/RIB).
|
||||
|
||||
**Tech Stack:** Symfony 8 / API Platform 4 (PHP 8.4, PHPUnit) ; Nuxt 4 / Vue 3 / TypeScript / Vitest.
|
||||
|
||||
**Spec de référence :** `docs/superpowers/specs/2026-06-04-client-collection-blocks-validation-design.md`
|
||||
|
||||
**Pré-vol :** `make start` (containers up), branche de travail = celle de la MR (`feat/erp-107-validation-messages-fr`) ou une branche dédiée selon décision utilisateur.
|
||||
|
||||
---
|
||||
|
||||
## Structure des fichiers
|
||||
|
||||
**Back — modifiés :**
|
||||
- `src/Module/Commercial/Domain/Entity/ClientContact.php` — `read: false` sur `Post`
|
||||
- `src/Module/Commercial/Domain/Entity/ClientAddress.php` — `read: false` sur `Post`
|
||||
- `src/Module/Commercial/Domain/Entity/ClientRib.php` — `read: false` sur `Post`
|
||||
- `src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientContactProcessor.php` — `linkParent` → 404
|
||||
- `.../Processor/ClientAddressProcessor.php` — idem
|
||||
- `.../Processor/ClientRibProcessor.php` — idem
|
||||
- `tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php` — helper `seedContact()`
|
||||
- `tests/Module/Commercial/Api/ClientSubResourceApiTest.php` — tests de non-régression
|
||||
|
||||
**Front — modifiés :**
|
||||
- `frontend/modules/commercial/composables/useClientFormErrors.ts` — méthode `submitRows()`
|
||||
- `frontend/modules/commercial/composables/__tests__/useClientFormErrors.spec.ts` — créé (test unitaire)
|
||||
- `frontend/modules/commercial/pages/clients/new.vue` — branchements (3 submits)
|
||||
- `frontend/modules/commercial/pages/clients/[id]/edit.vue` — branchements (3 submits)
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : Back — test rouge (POST sur client à ≥2 enfants)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php`
|
||||
- Test: `tests/Module/Commercial/Api/ClientSubResourceApiTest.php`
|
||||
|
||||
- [ ] **Step 1 : Ajouter un helper de seed de contact à la base de test**
|
||||
|
||||
Dans `AbstractCommercialApiTestCase.php`, ajouter (sous `seedClient`, avant `cleanupCommercialTestData`) :
|
||||
|
||||
```php
|
||||
/**
|
||||
* Seede directement un ClientContact en base (sans passer par l'API), pour
|
||||
* preparer un client deja dote de N contacts. Au moins le prenom est pose
|
||||
* (RG-1.05 / CHECK chk_client_contact_name).
|
||||
*/
|
||||
protected function seedContact(ClientEntity $client, string $firstName): \App\Module\Commercial\Domain\Entity\ClientContact
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$contact = new \App\Module\Commercial\Domain\Entity\ClientContact();
|
||||
$contact->setClient($client);
|
||||
$contact->setFirstName($firstName);
|
||||
$em->persist($contact);
|
||||
$em->flush();
|
||||
|
||||
return $contact;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Écrire les tests rouges**
|
||||
|
||||
Dans `ClientSubResourceApiTest.php`, ajouter dans la section `// === Contacts ===` :
|
||||
|
||||
```php
|
||||
/**
|
||||
* Regression ERP (bug subresource Link toProperty) : POST d'un contact sur un
|
||||
* client qui en a DEJA >= 2 ne doit pas exploser en 500
|
||||
* (NonUniqueResultException sur la resolution du parent), mais creer (201).
|
||||
*/
|
||||
public function testPostContactOnClientWithTwoExistingContactsReturns201(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Contact Multi');
|
||||
$this->seedContact($seed, 'Alpha');
|
||||
$this->seedContact($seed, 'Beta');
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['firstName' => 'Gamma'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Meme contexte (>= 2 contacts existants) : un email invalide doit produire
|
||||
* une 422 par champ (la validation est bien atteinte), pas une 500.
|
||||
*/
|
||||
public function testPostInvalidContactOnPopulatedClientReturns422OnField(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Contact Multi Bad');
|
||||
$this->seedContact($seed, 'Alpha');
|
||||
$this->seedContact($seed, 'Beta');
|
||||
|
||||
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['firstName' => 'Gamma', 'email' => 'pas-un-email'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
$byPath = [];
|
||||
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
|
||||
$byPath[$v['propertyPath']] = $v['message'];
|
||||
}
|
||||
self::assertArrayHasKey('email', $byPath);
|
||||
self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Lancer les tests, vérifier qu'ils échouent (500 au lieu de 201/422)**
|
||||
|
||||
Run : `make test` (ou ciblé dans le container : `docker exec php-starseed-fpm php bin/phpunit --filter ClientSubResourceApiTest`)
|
||||
Expected : les 2 nouveaux tests ÉCHOUENT (HTTP 500 `NonUniqueResultException`). `testPostContactOnClient...` reçoit 500, pas 201.
|
||||
|
||||
- [ ] **Step 4 : Commit (test rouge)**
|
||||
|
||||
```bash
|
||||
git add tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php tests/Module/Commercial/Api/ClientSubResourceApiTest.php
|
||||
git commit -m "test(commercial) : reproduit la 500 NonUniqueResult au POST contact sur client peuple (ERP-107)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Back — fix (read:false + linkParent durci) → tests verts
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Module/Commercial/Domain/Entity/ClientContact.php:48-57`
|
||||
- Modify: `src/Module/Commercial/Domain/Entity/ClientAddress.php:61-70`
|
||||
- Modify: `src/Module/Commercial/Domain/Entity/ClientRib.php:52-61`
|
||||
- Modify: `.../State/Processor/ClientContactProcessor.php:76-94`
|
||||
- Modify: `.../State/Processor/ClientAddressProcessor.php:63-81`
|
||||
- Modify: `.../State/Processor/ClientRibProcessor.php:65-83`
|
||||
|
||||
- [ ] **Step 1 : `read: false` sur les 3 opérations `Post`**
|
||||
|
||||
`ClientContact.php`, opération `Post` — ajouter la ligne `read: false,` :
|
||||
|
||||
```php
|
||||
new Post(
|
||||
uriTemplate: '/clients/{clientId}/contacts',
|
||||
uriVariables: [
|
||||
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
|
||||
],
|
||||
// read:false : pas de stade lecture du parent (le Link toProperty
|
||||
// resoudrait l'enfant et casse en NonUniqueResult des >= 2 enfants).
|
||||
// Le parent est rattache par ClientContactProcessor::linkParent.
|
||||
read: false,
|
||||
security: "is_granted('commercial.clients.manage')",
|
||||
normalizationContext: ['groups' => ['client_contact:read']],
|
||||
denormalizationContext: ['groups' => ['client_contact:write']],
|
||||
processor: ClientContactProcessor::class,
|
||||
),
|
||||
```
|
||||
|
||||
`ClientAddress.php` — idem dans son `Post` (`security: commercial.clients.manage`, processor `ClientAddressProcessor`), commentaire pointant `ClientAddressProcessor::linkParent`.
|
||||
|
||||
`ClientRib.php` — idem dans son `Post` (`security: commercial.clients.accounting.manage`, processor `ClientRibProcessor`), commentaire pointant `ClientRibProcessor::linkParent`.
|
||||
|
||||
- [ ] **Step 2 : Durcir les 3 `linkParent` (404 si parent absent)**
|
||||
|
||||
Dans chaque processor, ajouter l'import en tête de fichier :
|
||||
|
||||
```php
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
```
|
||||
|
||||
`ClientContactProcessor::linkParent` — remplacer le bloc final par :
|
||||
|
||||
```php
|
||||
if (null === $clientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$client = $clientId instanceof Client
|
||||
? $clientId
|
||||
: $this->em->getRepository(Client::class)->find($clientId);
|
||||
|
||||
// read:false sur le POST : sans stade lecture, un parent introuvable
|
||||
// n'est plus intercepte en amont -> 404 explicite (sinon 500 au persist
|
||||
// sur client_id NOT NULL).
|
||||
if (!$client instanceof Client) {
|
||||
throw new NotFoundHttpException('Client introuvable.');
|
||||
}
|
||||
|
||||
$contact->setClient($client);
|
||||
```
|
||||
|
||||
`ClientAddressProcessor::linkParent` — idem avec `$address->setClient($client);`.
|
||||
`ClientRibProcessor::linkParent` — idem avec `$rib->setClient($client);`.
|
||||
|
||||
- [ ] **Step 3 : Lancer les tests, vérifier qu'ils passent**
|
||||
|
||||
Run : `make test`
|
||||
Expected : les 2 tests de Task 1 PASSENT (201 + 422 `propertyPath=email`). Aucun test existant cassé (notamment `testPostContactInvalidEmailReturns422WithFrenchMessageOnField` et les tests d'archi ERP-107 restent verts).
|
||||
|
||||
- [ ] **Step 4 : Lint PHP**
|
||||
|
||||
Run : `make php-cs-fixer-allow-risky`
|
||||
Expected : 0 fichier à corriger (ou corrections appliquées et re-vérifiées).
|
||||
|
||||
- [ ] **Step 5 : Commit (fix back)**
|
||||
|
||||
```bash
|
||||
git add src/Module/Commercial/Domain/Entity/ClientContact.php src/Module/Commercial/Domain/Entity/ClientAddress.php src/Module/Commercial/Domain/Entity/ClientRib.php src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientContactProcessor.php src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientRibProcessor.php
|
||||
git commit -m "fix(commercial) : POST sous-ressource client en read:false + parent 404 (corrige 500 NonUniqueResult, ERP-107)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Back — germes adresses + RIB (verrouille les 3 sous-ressources)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php` (helpers `seedAddress`, `seedRib`)
|
||||
- Test: `tests/Module/Commercial/Api/ClientSubResourceApiTest.php`
|
||||
|
||||
- [ ] **Step 1 : Helpers de seed adresse + RIB**
|
||||
|
||||
Dans `AbstractCommercialApiTestCase.php`, ajouter :
|
||||
|
||||
```php
|
||||
/** Seede une adresse minimale valide (RG : CP/ville/rue requis). */
|
||||
protected function seedAddress(ClientEntity $client, string $city): \App\Module\Commercial\Domain\Entity\ClientAddress
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$address = new \App\Module\Commercial\Domain\Entity\ClientAddress();
|
||||
$address->setClient($client);
|
||||
$address->setPostalCode('33000');
|
||||
$address->setCity($city);
|
||||
$address->setStreet('1 rue du Test');
|
||||
$em->persist($address);
|
||||
$em->flush();
|
||||
|
||||
return $address;
|
||||
}
|
||||
|
||||
/** Seede un RIB valide (BIC/IBAN conformes). */
|
||||
protected function seedRib(ClientEntity $client, string $label): \App\Module\Commercial\Domain\Entity\ClientRib
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$rib = new \App\Module\Commercial\Domain\Entity\ClientRib();
|
||||
$rib->setClient($client);
|
||||
$rib->setLabel($label);
|
||||
$rib->setBic('BNPAFRPPXXX');
|
||||
$rib->setIban('FR1420041010050500013M02606');
|
||||
$em->persist($rib);
|
||||
$em->flush();
|
||||
|
||||
return $rib;
|
||||
}
|
||||
```
|
||||
|
||||
> Note : si une propriété est non-nullable et absente ci-dessus (ex. `position`, flags d'adresse), poser les setters correspondants avec une valeur par défaut neutre — vérifier les entités `ClientAddress` / `ClientRib` au moment de l'écriture.
|
||||
|
||||
- [ ] **Step 2 : Tests de non-régression adresses + RIB**
|
||||
|
||||
Dans `ClientSubResourceApiTest.php`, section adresses puis RIB :
|
||||
|
||||
```php
|
||||
public function testPostAddressOnClientWithTwoExistingAddressesReturns201(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Addr Multi');
|
||||
$this->seedAddress($seed, 'Bordeaux');
|
||||
$this->seedAddress($seed, 'Lyon');
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['postalCode' => '75001', 'city' => 'Paris', 'street' => '2 rue Neuve'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testPostRibOnClientWithTwoExistingRibsReturns201(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Rib Multi');
|
||||
$this->seedRib($seed, 'Compte 1');
|
||||
$this->seedRib($seed, 'Compte 2');
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['label' => 'Compte 3', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
```
|
||||
|
||||
> Le POST RIB exige `commercial.clients.accounting.manage` — `admin` (ROLE_ADMIN) l'a. Si une 403 apparaît, vérifier le compte de test.
|
||||
|
||||
- [ ] **Step 3 : Lancer, vérifier vert**
|
||||
|
||||
Run : `make test`
|
||||
Expected : PASS (les 2 nouveaux tests verts grâce au fix de Task 2).
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php tests/Module/Commercial/Api/ClientSubResourceApiTest.php
|
||||
git commit -m "test(commercial) : verrouille POST adresses/RIB sur client peuple (ERP-107)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Front — helper `submitRows` + test unitaire
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/modules/commercial/composables/useClientFormErrors.ts`
|
||||
- Create: `frontend/modules/commercial/composables/__tests__/useClientFormErrors.spec.ts`
|
||||
|
||||
- [ ] **Step 1 : Écrire le test rouge**
|
||||
|
||||
Créer `useClientFormErrors.spec.ts` :
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { useClientFormErrors } from '../useClientFormErrors'
|
||||
|
||||
// Construit une erreur facon useApi : 422 avec violations Hydra.
|
||||
function http422(path: string, message: string) {
|
||||
return { response: { status: 422, _data: { violations: [{ propertyPath: path, message }] } } }
|
||||
}
|
||||
|
||||
describe('useClientFormErrors.submitRows', () => {
|
||||
it('tente TOUS les blocs et mappe les erreurs par index, sans stopper au premier echec', async () => {
|
||||
const { contactErrors, submitRows } = useClientFormErrors()
|
||||
const seen: number[] = []
|
||||
const onUnmapped = vi.fn()
|
||||
|
||||
const saveRow = async (_row: unknown, index: number) => {
|
||||
seen.push(index)
|
||||
if (index === 1) throw http422('email', 'Email invalide')
|
||||
}
|
||||
|
||||
const hasError = await submitRows(
|
||||
[{ a: 0 }, { a: 1 }, { a: 2 }],
|
||||
contactErrors,
|
||||
saveRow,
|
||||
onUnmapped,
|
||||
)
|
||||
|
||||
expect(seen).toEqual([0, 1, 2]) // tous les blocs tentes
|
||||
expect(hasError).toBe(true)
|
||||
expect(contactErrors.value[1]).toEqual({ email: 'Email invalide' })
|
||||
expect(contactErrors.value[0]).toBeUndefined()
|
||||
expect(onUnmapped).not.toHaveBeenCalled() // 422 mappee, pas de fallback
|
||||
})
|
||||
|
||||
it('saute les lignes filtrees par shouldSkip et renvoie false si tout passe', async () => {
|
||||
const { contactErrors, submitRows } = useClientFormErrors()
|
||||
const saved: number[] = []
|
||||
|
||||
const hasError = await submitRows(
|
||||
[{ skip: true }, { skip: false }],
|
||||
contactErrors,
|
||||
async (_row, index) => { saved.push(index) },
|
||||
vi.fn(),
|
||||
(row: { skip: boolean }) => row.skip,
|
||||
)
|
||||
|
||||
expect(saved).toEqual([1])
|
||||
expect(hasError).toBe(false)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer, vérifier l'échec**
|
||||
|
||||
Run : `make nuxt-test` (ou ciblé : `docker exec <node> npx vitest run useClientFormErrors`)
|
||||
Expected : FAIL — `submitRows` n'existe pas encore.
|
||||
|
||||
- [ ] **Step 3 : Implémenter `submitRows`**
|
||||
|
||||
Dans `useClientFormErrors.ts`, ajouter la méthode (dans la fonction, après `mapRowError`) et l'exposer dans le `return` :
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Soumet TOUS les blocs d'une collection (contacts/adresses/RIB) en collectant
|
||||
* les erreurs par index : on n'arrete PAS au premier bloc en echec (ERP-101).
|
||||
* Reinitialise le tableau d'erreurs cible, tente chaque ligne via `saveRow`,
|
||||
* mappe les 422 inline (mapRowError) ou delegue le fallback a `onUnmappedError`.
|
||||
* Retourne true si au moins un bloc a echoue (le caller ne valide alors pas l'onglet).
|
||||
*/
|
||||
async function submitRows<T>(
|
||||
rows: T[],
|
||||
target: Ref<Record<string, string>[]>,
|
||||
saveRow: (row: T, index: number) => Promise<void>,
|
||||
onUnmappedError: (error: unknown, index: number) => void,
|
||||
shouldSkip?: (row: T, index: number) => boolean,
|
||||
): Promise<boolean> {
|
||||
target.value = []
|
||||
let hasError = false
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
if (shouldSkip?.(rows[index], index)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await saveRow(rows[index], index)
|
||||
}
|
||||
catch (error) {
|
||||
if (!mapRowError(error, target, index)) {
|
||||
onUnmappedError(error, index)
|
||||
}
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
|
||||
return hasError
|
||||
}
|
||||
```
|
||||
|
||||
Ajouter `submitRows` à l'objet retourné par `useClientFormErrors`.
|
||||
|
||||
- [ ] **Step 4 : Lancer, vérifier vert**
|
||||
|
||||
Run : `make nuxt-test`
|
||||
Expected : PASS (les 2 cas verts).
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/modules/commercial/composables/useClientFormErrors.ts frontend/modules/commercial/composables/__tests__/useClientFormErrors.spec.ts
|
||||
git commit -m "feat(commercial) : submitRows collecte les erreurs de tous les blocs de collection (ERP-101)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Front — brancher `submitRows` dans new.vue + edit.vue
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/modules/commercial/pages/clients/new.vue` (`submitContacts`, `submitAddresses`, boucle RIB de `submitAccounting`)
|
||||
- Modify: `frontend/modules/commercial/pages/clients/[id]/edit.vue` (les 3 équivalents)
|
||||
|
||||
- [ ] **Step 1 : Récupérer `submitRows` du composable**
|
||||
|
||||
Dans `new.vue` ET `edit.vue`, ajouter `submitRows` à la déstructuration de `useClientFormErrors()` :
|
||||
|
||||
```ts
|
||||
const {
|
||||
mainErrors,
|
||||
informationErrors,
|
||||
accountingErrors,
|
||||
contactErrors,
|
||||
addressErrors,
|
||||
ribErrors,
|
||||
mapRowError,
|
||||
submitRows,
|
||||
} = useClientFormErrors()
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Réécrire `submitContacts` (new.vue)**
|
||||
|
||||
Remplacer le corps de la boucle par un appel à `submitRows` :
|
||||
|
||||
```ts
|
||||
async function submitContacts(): Promise<void> {
|
||||
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
const hasError = await submitRows(
|
||||
contacts.value,
|
||||
contactErrors,
|
||||
async (contact) => {
|
||||
const body = {
|
||||
firstName: contact.firstName || null,
|
||||
lastName: contact.lastName || null,
|
||||
jobTitle: contact.jobTitle || null,
|
||||
phonePrimary: contact.phonePrimary || null,
|
||||
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
|
||||
email: contact.email || null,
|
||||
}
|
||||
if (contact.id === null) {
|
||||
const created = await api.post<ContactResponse>(
|
||||
`/clients/${clientId.value}/contacts`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
contact.id = created.id
|
||||
contact.iri = created['@id'] ?? null
|
||||
}
|
||||
else {
|
||||
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
(error) => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
||||
(contact) => !isContactNamed(contact),
|
||||
)
|
||||
if (hasError) return
|
||||
completeTab('contact')
|
||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Réécrire `submitAddresses` (new.vue)**
|
||||
|
||||
```ts
|
||||
async function submitAddresses(): Promise<void> {
|
||||
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
const hasError = await submitRows(
|
||||
addresses.value,
|
||||
addressErrors,
|
||||
async (address) => {
|
||||
const body = {
|
||||
isProspect: address.isProspect,
|
||||
isDelivery: address.isDelivery,
|
||||
isBilling: address.isBilling,
|
||||
country: address.country,
|
||||
postalCode: address.postalCode || null,
|
||||
city: address.city || null,
|
||||
street: address.street || null,
|
||||
streetComplement: address.streetComplement || null,
|
||||
categories: address.categoryIris,
|
||||
sites: address.siteIris,
|
||||
contacts: address.contactIris,
|
||||
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
|
||||
}
|
||||
if (address.id === null) {
|
||||
const created = await api.post<{ id: number }>(
|
||||
`/clients/${clientId.value}/addresses`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
address.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
(error) => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
||||
)
|
||||
if (hasError) return
|
||||
completeTab('address')
|
||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Réécrire la boucle RIB de `submitAccounting` (new.vue)**
|
||||
|
||||
Garder le PATCH scalaire inchangé (1) ; remplacer la boucle (2) :
|
||||
|
||||
```ts
|
||||
// 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs).
|
||||
const ribHasError = await submitRows(
|
||||
ribs.value,
|
||||
ribErrors,
|
||||
async (rib) => {
|
||||
const body = { label: rib.label, bic: rib.bic, iban: rib.iban }
|
||||
if (rib.id === null) {
|
||||
const created = await api.post<{ id: number }>(
|
||||
`/clients/${clientId.value}/ribs`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
rib.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
(error) => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
|
||||
(rib) => !ribIsComplete(rib),
|
||||
)
|
||||
if (ribHasError) return
|
||||
|
||||
completeTab('accounting')
|
||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||
```
|
||||
|
||||
> Retirer le `ribErrors.value = []` désormais fait par `submitRows`. Le `accountingErrors.clearErrors()` du PATCH scalaire reste.
|
||||
|
||||
- [ ] **Step 5 : Mirror dans edit.vue**
|
||||
|
||||
Appliquer les mêmes réécritures aux `submitContacts` / `submitAddresses` / boucle RIB de `submitAccounting` d'`edit.vue`. Conserver le **fallback d'erreur propre à edit.vue** (si edit.vue utilise `showError(...)` au lieu de `toast.error(...)`, passer ce fallback comme `onUnmappedError`). Vérifier les noms des refs (`clientId` peut y être l'id de route).
|
||||
|
||||
- [ ] **Step 6 : Vérifier le typecheck + tests front**
|
||||
|
||||
Run : `make nuxt-test`
|
||||
Expected : PASS. Aucune régression des specs existantes (`ClientContactBlock.spec.ts`, etc.).
|
||||
|
||||
- [ ] **Step 7 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/modules/commercial/pages/clients/new.vue "frontend/modules/commercial/pages/clients/[id]/edit.vue"
|
||||
git commit -m "feat(commercial) : valide tous les blocs contacts/adresses/RIB et affiche les erreurs par bloc (ERP-101)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 : Vérification finale + golden path manuel
|
||||
|
||||
- [ ] **Step 1 : Suite complète back**
|
||||
|
||||
Run : `make test` puis `make php-cs-fixer-allow-risky`
|
||||
Expected : tout vert, 0 fichier à corriger.
|
||||
|
||||
- [ ] **Step 2 : Suite complète front**
|
||||
|
||||
Run : `make nuxt-test`
|
||||
Expected : tout vert.
|
||||
|
||||
- [ ] **Step 3 : Golden path manuel (`make dev-nuxt`, port 3004)**
|
||||
|
||||
Scénario : ouvrir un client à 3 contacts (compte `admin`), onglet Contacts, ajouter un bloc avec email invalide + un autre bloc avec prénom/nom vides → Valider.
|
||||
Attendu : **pas de 500** ; « L'adresse email n'est pas valide. » sous l'email du bon bloc ET « Le prénom ou le nom du contact est obligatoire. » sous le prénom de l'autre bloc, **affichés simultanément**. L'onglet ne se valide pas tant qu'une erreur subsiste. Idem à vérifier rapidement sur Adresses et RIB.
|
||||
|
||||
- [ ] **Step 4 : Si une vérif échoue ou ne peut être lancée, le dire explicitement** (ne pas annoncer « fini »).
|
||||
|
||||
---
|
||||
|
||||
## Self-review (auteur du plan)
|
||||
|
||||
- **Couverture spec §3.1 (back)** : Task 2 (read:false + linkParent 404) ✓ ; §3.2 (front collect-all) : Tasks 4-5 ✓ ; §3.3 (helper réutilisable) : Task 4 `submitRows` ✓ ; §4 tests : Tasks 1, 3 (back), 4 (front) + Task 6 golden path ✓.
|
||||
- **Périmètre 3 sous-ressources** : contacts (Task 1-2), adresses + RIB (Task 3 + branchements Task 5) ✓.
|
||||
- **Décision « inline seul »** : aucun toast succès si `hasError` ; pas de toast récap ✓.
|
||||
- **Pas de placeholder** : le seul point ouvert est la note Task 3 Step 1 (setters non-nullables éventuels d'adresse/RIB à compléter en lisant les entités) — à lever à l'écriture. Cohérence des noms : `submitRows` utilisé identiquement en Task 4 et Task 5.
|
||||
@@ -10,8 +10,8 @@ trous, zéro duplication »).
|
||||
|
||||
ERP-60 n'écrit QUE les tests des RG non déjà couvertes par la stack, et mappe ici
|
||||
l'intégralité des RG (existantes + nouvelles + déléguées). Les tests dépendants
|
||||
des **rôles métier** (matrice RBAC bureau/compta/commerciale/usine + RG-1.04
|
||||
fonctionnel) sont **délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le
|
||||
des **rôles métier** (matrice RBAC bureau/compta/commerciale/usine) sont
|
||||
**délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le
|
||||
merge de la stack.
|
||||
|
||||
## Mapping RG → test
|
||||
@@ -21,7 +21,7 @@ merge de la stack.
|
||||
| ~~RG-1.01~~ | _(supprimée V1 — refonte-contact)_ contact inline retiré du Client ; complétude couverte par RG-1.05 / RG-1.14 (`ClientContact`) | — | refonte-contact |
|
||||
| ~~RG-1.02~~ | _(supprimée du Client V1)_ téléphones inline retirés du Client (testés sur `ClientContact`) | — | refonte-contact |
|
||||
| RG-1.03 | distributor/broker exclusifs + type catégorie | `ClientApiTest::testPostWithDistributorAndBrokerReturns422` ; `::testPostDistributorReferencingNonDistributorReturns422` ; `::testPostValidDistributorReturns201` ; `ClientProcessorTest` (unit) | ERP-55 |
|
||||
| RG-1.04 | Onglet Information obligatoire pour rôle Commerciale | `ClientProcessorTest::testCommercialeIncompleteInformationIsUnprocessable` ; `::testNonCommercialeSkipsInformationCompleteness` (unit, dormant). **Test fonctionnel + durcissement → ERP-74** | ERP-55 / **ERP-74** |
|
||||
| ~~RG-1.04~~ | _(SUPPRIMÉE)_ Onglet Information désormais facultatif pour tous les rôles (validation de complétude retirée) | — | ERP-55 / ERP-74 |
|
||||
| RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 |
|
||||
| RG-1.06/07/08 | Adresse prospect exclusive de livraison/facturation → 422 (Assert\Callback + CHECK filet) | `ClientAddressTest::testProspectAddressCannotBeDelivery` ; `::testProspectAddressCannotBeBilling` | ERP-60 / **ERP-76** |
|
||||
| RG-1.09 | Code postal `^[0-9]{4,5}$` → 422 | `ClientSubResourceApiTest::testPostAddressWithInvalidPostalCodeReturns422` | ERP-57 |
|
||||
@@ -60,8 +60,7 @@ merge de la stack.
|
||||
|
||||
- **Matrice RBAC différenciée** par rôle métier (Bureau / Compta / Commerciale /
|
||||
Usine) : 200/403 par verbe et par onglet selon le rôle.
|
||||
- **RG-1.04 fonctionnel** : PATCH onglet Information par une Commerciale avec
|
||||
champs incomplets → 422 ; même PATCH par Admin → 200 (+ durcissement code/spec).
|
||||
- ~~**RG-1.04 fonctionnel**~~ : _(SUPPRIMÉE)_ l'onglet Information est désormais facultatif pour tous les rôles.
|
||||
- Raison : ces rôles métier ne sont seedés qu'après le merge de la stack M1.
|
||||
|
||||
## Gaps & suivi
|
||||
|
||||
@@ -310,7 +310,7 @@ CREATE TABLE client (
|
||||
distributor_id INT REFERENCES client(id) ON DELETE SET NULL,
|
||||
broker_id INT REFERENCES client(id) ON DELETE SET NULL,
|
||||
triage_service BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
-- Onglet Information (Commerciale obligatoire — RG-1.04 — null sinon)
|
||||
-- Onglet Information (facultatif pour tous — RG-1.04 supprimée)
|
||||
description TEXT,
|
||||
competitors VARCHAR(255),
|
||||
founded_at DATE,
|
||||
@@ -864,8 +864,7 @@ Cf. § 2.6. Pattern Shared standard.
|
||||
|
||||
### Onglet Information
|
||||
|
||||
- **RG-1.04** _(durcie — ERP-74)_ : Pour un utilisateur portant le rôle métier **Commerciale**, **tous** les champs de l'onglet Information (`description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount`) sont obligatoires sur **POST et sur tout PATCH**, **indépendamment des champs réellement envoyés** (la condition d'intersection avec `client:write:information` a été retirée). Pour les autres rôles, ces champs restent optionnels. Implémenté via un validator custom `ClientInformationCompletenessValidator` invoqué systématiquement par le `ClientProcessor` quand le user porte le rôle Commerciale.
|
||||
- **Conséquence** : le POST n'exposant que le groupe `client:write:main`, l'onglet Information n'y est pas renseignable → une Commerciale obtient **422** sur tout POST (cf. § 8.1). La complétude se fait donc via les PATCH `client:write:information` ultérieurs. Un Admin (non gaté) crée normalement (201).
|
||||
- ~~**RG-1.04**~~ _(SUPPRIMÉE)_ : l'onglet Information est désormais **facultatif pour tous les rôles**, y compris Commerciale. Les champs `description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount` restent optionnels (colonnes nullable, aucune validation de complétude). Le validator `ClientInformationCompletenessValidator` et le gating par rôle métier dans le `ClientProcessor` ont été retirés.
|
||||
|
||||
### Onglet Contact
|
||||
|
||||
@@ -883,6 +882,7 @@ Cf. § 2.6. Pattern Shared standard.
|
||||
|
||||
### Onglet Comptabilité
|
||||
|
||||
- **RG-1.30** _(ajoutée — correctif incohérence spec-front/spec-back)_ : à la **validation complète de l'onglet Comptabilité**, les six champs scalaires `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType` sont **obligatoires** (alignement sur spec-front § Onglet Comptabilité). Colonnes `nullable` en base (l'onglet est rempli dans un second temps, et l'onglet principal ne les envoie pas) + validateur contextuel `ClientAccountingCompletenessValidator` invoqué par le `ClientProcessor` (parti pris : nullable + validateur d'onglet plutôt qu'un `Assert\NotBlank` sur l'entité). Déclenchement : uniquement quand **les six champs sont présents dans le payload** (le front les envoie toujours ensemble via « Valider ») ; un PATCH ciblant un sous-ensemble de champs comptables (édition ponctuelle) n'est pas soumis à la complétude. Chaque champ manquant → 422 sur son `propertyPath` (mapping inline front, ERP-101). `bank` reste hors complétude (conditionnel RG-1.12).
|
||||
- **RG-1.12** : Le champ `bank` est visible et obligatoire **uniquement** si `paymentType.code = 'VIREMENT'`. Validation server-side dans le `ClientProcessor` : si `payment_type.code = VIREMENT` et `bank IS NULL` → 422.
|
||||
- **RG-1.13** : Les champs RIB (`label`, `bic`, `iban`) sont obligatoires si **au moins un bloc RIB est présent ET** `paymentType.code = 'LCR'`. C'est-à-dire :
|
||||
- Si `paymentType.code = LCR` ET `client.ribs.count() = 0` → 422 « Au moins un RIB est obligatoire pour le type LCR ».
|
||||
@@ -937,7 +937,7 @@ Cf. § 2.6. Pattern Shared standard.
|
||||
- [ ] ~~RG-1.02~~ _(supprimée du Client V1)_ : plus de téléphones inline sur le Client (téléphones testés sur `ClientContact`)
|
||||
- [ ] **RG-1.03** : POST avec distributor ET broker → 422 ; POST distributor seul → 201
|
||||
- [ ] **RG-1.03** : POST distributor référençant un client SANS catégorie de code DISTRIBUTEUR → 422 (validation custom `ClientProcessor::hasCategoryCode`)
|
||||
- [ ] **RG-1.04** : PATCH onglet Information par un user Commerciale avec champs incomplets → 422 ; même PATCH par Admin → 200
|
||||
- [x] ~~**RG-1.04**~~ _(SUPPRIMÉE)_ : onglet Information facultatif pour tous les rôles (plus de validation de complétude)
|
||||
- [ ] **RG-1.05** : POST contact sans firstName ni lastName → 422 (BDD CHECK lève une exception)
|
||||
- [ ] **RG-1.06/07/08** : POST adresse avec isProspect=true ET isDelivery=true → 422 / CHECK
|
||||
- [ ] **RG-1.09** : POST adresse avec postalCode invalide (3 chiffres) → 422 ; CP/ville incohérents → 200 (pas de validation stricte côté serveur)
|
||||
|
||||
@@ -13,7 +13,7 @@ date_redaction: 2026-05-28
|
||||
|
||||
# === LIENS ===
|
||||
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898"
|
||||
regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.04, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13, RG-1.14, RG-1.15, RG-1.16, RG-1.17, RG-1.18, RG-1.19, RG-1.20, RG-1.21, RG-1.22, RG-1.23, RG-1.24, RG-1.25, RG-1.26, RG-1.27, RG-1.28, RG-1.29]
|
||||
regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13, RG-1.14, RG-1.15, RG-1.16, RG-1.17, RG-1.18, RG-1.19, RG-1.20, RG-1.21, RG-1.22, RG-1.23, RG-1.24, RG-1.25, RG-1.26, RG-1.27, RG-1.28, RG-1.29]
|
||||
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||
lien_spec_back: ./spec-back.md
|
||||
|
||||
@@ -105,13 +105,13 @@ Saisir les informations de l'entreprise.
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Description** | `<MalioInputTextArea>` | Conditionnel | RG-1.04 (obligatoire pour rôle Commerciale) |
|
||||
| **Concurrents** | `<MalioInputText>` | Conditionnel | RG-1.04 |
|
||||
| **Date de création** (de l'entreprise) | `<input type="date">` (exception Malio — pas de composant date couvert) | Conditionnel | RG-1.04 |
|
||||
| **Nombre de salariés** | `<MalioInputNumber>` | Conditionnel | RG-1.04 |
|
||||
| **CA €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
|
||||
| **Dirigeant** | `<MalioInputText>` | Conditionnel | RG-1.04 |
|
||||
| **Résultat €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
|
||||
| **Description** | `<MalioInputTextArea>` | Non | — _(onglet facultatif, RG-1.04 supprimée)_ |
|
||||
| **Concurrents** | `<MalioInputText>` | Non | — |
|
||||
| **Date de création** (de l'entreprise) | `<input type="date">` (exception Malio — pas de composant date couvert) | Non | — |
|
||||
| **Nombre de salariés** | `<MalioInputNumber>` | Non | — |
|
||||
| **CA €** | `<MalioInputAmount>` | Non | — |
|
||||
| **Dirigeant** | `<MalioInputText>` | Non | — |
|
||||
| **Résultat €** | `<MalioInputAmount>` | Non | — |
|
||||
|
||||
**Action** : « Valider » → PATCH partiel `/api/clients/{id}` (groupe `client:write:information`).
|
||||
|
||||
@@ -258,7 +258,8 @@ Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse.
|
||||
|
||||
- Composable dédié `useAddressAutocomplete()` (à créer en M1).
|
||||
- Appel HTTP **direct depuis le front** (CORS OK), pas de proxy back.
|
||||
- Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions adresse.
|
||||
- Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}` (sans filtre `type`) → suggestions adresse.
|
||||
- ⚠ **Ne pas forcer `type=housenumber`** sur la recherche d'adresse (corrigé en ERP-66) : la BAN ne renvoie un résultat de ce type qu'une fois un numéro saisi, donc une recherche par nom de rue (« boulevard du port ») renverrait **0 résultat** pendant toute la frappe. Sans filtre `type`, la BAN classe rues + numéros par pertinence — comportement d'autocomplétion attendu.
|
||||
- Cas dégradé : si l'API ne répond pas (offline, timeout), le champ Ville devient un `<MalioInputText>` libre éditable + toast d'avertissement. Validation serveur acceptera la saisie libre.
|
||||
|
||||
## Points laissés ouverts par la V0 (résolus côté back)
|
||||
|
||||
@@ -126,7 +126,7 @@ Toutes les entités métier nouvelles implémentent `TimestampableInterface` + `
|
||||
|
||||
Notes (miroir M1) :
|
||||
- **Compta édite uniquement l'onglet Comptabilité** (`accounting.manage`) d'un fournisseur existant. Compta ne peut pas **créer** un fournisseur (pas de `manage` global).
|
||||
- **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back, 2 niveaux : `security` API Platform + `SupplierProvider`).
|
||||
- **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back). Mécanisme réel (le code fait foi) : le groupe de lecture `supplier:read:accounting` n'est **pas** dans le contexte de sérialisation par défaut ; le `SupplierReadGroupContextBuilder` ne l'**ajoute** dynamiquement que si l'utilisateur porte `commercial.suppliers.accounting.view` (gating **par ajout** de groupe, jamais par retrait). Sans la permission, les champs comptables (et les RIB) ne sont donc jamais sérialisés. La colonne SIREN de l'export XLSX suit la même règle (`accounting.view`).
|
||||
- **Bureau** : `view` + `manage` (tout sauf Comptabilité).
|
||||
- **Usine** : aucune permission → item sidebar invisible, accès direct 403.
|
||||
|
||||
@@ -159,9 +159,11 @@ final class SupplierFieldNormalizer
|
||||
|
||||
Le formatage `XX XX XX XX XX` est fait à l'affichage côté front. Le back stocke `0612345678` (chiffres seuls).
|
||||
|
||||
### 2.12 Liste : embed catégories + sites + fetch-joins (cohérence M1/ERP-62)
|
||||
### 2.12 Liste : embed catégories + sites + hydratation anti-N+1 (cohérence M1/ERP-62)
|
||||
|
||||
Décision d'alignement (02/06/2026) : la **liste** `GET /api/suppliers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme la liste Clients après ERP-62 — et **non** des champs dérivés aplatis. Conséquence performance : le `DoctrineSupplierRepository` **DOIT** poser des **fetch-joins** (`leftJoin`+`addSelect`) sur `categories` et `addresses.sites` dans la requête de liste pour éviter le N+1. Les `sites` de la liste sont agrégés/dédoublonnés via `Supplier::getSites()` (cf. § 3.3). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité — source de vérité unique, le front ne le redéfinit pas.
|
||||
Décision d'alignement (02/06/2026) : la **liste** `GET /api/suppliers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme la liste Clients après ERP-62 — et **non** des champs dérivés aplatis.
|
||||
|
||||
Conséquence performance — **implémentation réelle (le code fait foi)** : le `DoctrineSupplierRepository` **ne fetch-joine PAS** les to-many dans la requête de sélection (`createListQueryBuilder` ne fait que filtres + tri). L'anti-N+1 passe par `hydrateListCollections()` (puis `hydrateContacts()`) : une fois le jeu de fournisseurs borné (page ou export), des requêtes **`IN` bornées séparées** remplissent `categories`, puis `addresses.sites`, puis `contacts` sur les **mêmes** instances `Supplier` (identity map). Ce découpage évite le **produit cartésien** qu'un fetch-join combiné `categories × addresses.sites` imposerait aux chemins non paginés (export, `?pagination=false`). Les `sites` de la liste sont agrégés/dédoublonnés via `Supplier::getSites()` (cf. § 3.3). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité — source de vérité unique, le front ne le redéfinit pas.
|
||||
|
||||
> Dépendance confirmée sur le JSON réel (#82 mergé) : `Category` expose `code`/`name` sous `category:read` ; `Site` expose `name`/`postalCode`/`city`/`color` sous `site:read` (**pas de `code`**). L'embed est pleinement matérialisé.
|
||||
|
||||
@@ -213,6 +215,8 @@ Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrati
|
||||
|
||||
> **Rappel règle ABSOLUE n°12** : chaque colonne créée ci-dessous DOIT recevoir son `COMMENT ON COLUMN`. Les 4 colonnes Timestampable/Blamable passent par le helper `addStandardTimestampableBlamableComments($schema, '<table>')`. Le SQL ci-dessous montre la structure ; les `COMMENT ON COLUMN` (un par colonne métier) sont à écrire dans la migration (exemples §3.2.bis).
|
||||
|
||||
> **Types réels de la migration (le code fait foi)** : le SQL ci-dessous est *illustratif*. La migration mergée (`Version20260605130000`) utilise le **style aligné M1** : clés primaires en `INT GENERATED BY DEFAULT AS IDENTITY` (et **non** `SERIAL`) et horodatages en `TIMESTAMP(0) WITHOUT TIME ZONE` (et **non** `TIMESTAMPTZ`, car le `TimestampableBlamableTrait` mappe `datetime_immutable`). Garantit que `schema:update` reste un no-op une fois les entités mappées.
|
||||
|
||||
```sql
|
||||
-- =====================================================================
|
||||
-- Seed taxonomie : nouveau type FOURNISSEUR (référentiels comptables = M1, non recréés)
|
||||
@@ -422,8 +426,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
// Cohérence M1/ERP-62 : la LISTE embarque catégories + sites (pas de
|
||||
// champ dérivé aplati). Maillon (c) : category:read + site:read dans
|
||||
// le contexte pour exposer Category(code/name) + Site(name/postalCode).
|
||||
// ⚠ Le SupplierRepository DOIT fetch-join categories + addresses.sites
|
||||
// pour éviter le N+1 sur la liste (cf. § 2.12).
|
||||
// ⚠ Anti-N+1 : pas de fetch-join dans la requête de liste — le
|
||||
// SupplierRepository hydrate categories/sites/contacts via des requêtes
|
||||
// IN bornées séparées (hydrateListCollections), pour éviter le produit
|
||||
// cartésien sur les chemins non paginés (export) — cf. § 2.12.
|
||||
normalizationContext: ['groups' => [
|
||||
'supplier:read',
|
||||
'category:read',
|
||||
@@ -442,13 +448,14 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
normalizationContext: ['groups' => [
|
||||
'supplier:read',
|
||||
'supplier:item:read', // embed contacts / addresses
|
||||
'supplier:read:accounting', // scalaires compta + embed ribs (filtré par le Provider selon accounting.view)
|
||||
// ⚠ supplier:read:accounting est volontairement ABSENT ici : il est
|
||||
// AJOUTÉ dynamiquement par le SupplierReadGroupContextBuilder quand
|
||||
// l'user porte accounting.view (gating par ajout, pas par retrait —
|
||||
// parade bug #4 M1). Il porte les scalaires compta + l'embed ribs.
|
||||
'category:read', // embed des Category (id/code/name) — relation imbriquée
|
||||
'site:read', // embed des Site (id/name/postalCode/city/color, pas de code) — relation imbriquée
|
||||
'default:read',
|
||||
]],
|
||||
// Le Provider RETIRE supplier:read:accounting du contexte si l'user
|
||||
// n'a pas is_granted('commercial.suppliers.accounting.view').
|
||||
provider: SupplierProvider::class,
|
||||
),
|
||||
new Post(
|
||||
@@ -458,10 +465,13 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
processor: SupplierProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('commercial.suppliers.manage')",
|
||||
// Le SupplierProcessor inspecte les groupes envoyés pour autoriser
|
||||
// onglet par onglet (cf. § 2.10 + § 5). Patch des champs comptables
|
||||
// exige is_granted('commercial.suppliers.accounting.manage') ;
|
||||
// Security élargie : `manage` OU `accounting.manage` — le rôle Compta
|
||||
// n'a pas `manage` mais doit pouvoir éditer l'onglet Comptabilité d'un
|
||||
// fournisseur existant (§ 2.9). Le SupplierProcessor re-gate ensuite
|
||||
// onglet par onglet (mode strict RG-2.16) :
|
||||
security: "is_granted('commercial.suppliers.manage') or is_granted('commercial.suppliers.accounting.manage')",
|
||||
// Patch des champs comptables exige accounting.manage (guardAccounting) ;
|
||||
// champs main/information exigent manage (guardManage) ;
|
||||
// patch isArchived exige is_granted('commercial.suppliers.archive').
|
||||
normalizationContext: ['groups' => ['supplier:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => [
|
||||
@@ -711,91 +721,108 @@ Même pattern que les jumelles `Client*` (`#[Auditable]`, `TimestampableBlamable
|
||||
| Scalaires Comptabilité (siren, refs…) | `supplier:read:accounting` | ✅ (gated) | refs (`tvaMode`…) id+label ∈ `supplier:read:accounting` |
|
||||
| `ribs[]` (label/bic/iban) | `ribs` ∈ `supplier:read:accounting` | ✅ (gated) | — |
|
||||
|
||||
### 4.0.bis Réponses JSON de référence (DoD — à confirmer sur l'API réelle)
|
||||
### 4.0.bis Réponses JSON de référence (DoD — RÉELLES, capturées ERP-92)
|
||||
|
||||
> **Definition of Done de cette spec back (RETEX M1 §3)** : avant d'écrire les tickets front, créer un fournisseur de test et **coller ici les réponses RÉELLES** de `GET /api/suppliers` et `GET /api/suppliers/{id}`. Les containers n'étant pas lancés au moment de la rédaction, le JSON ci-dessous est le **contrat CIBLE** — à valider/remplacer par la réponse réelle (`make start` puis `curl`). Toute donnée affichée par le front DOIT apparaître dans ce JSON.
|
||||
> **Definition of Done CLÔTURÉE (ERP-92, 2026-06-05)** : les réponses ci-dessous sont **réelles**, capturées sur l'API de test via PHPUnit (`SupplierSerializationContractTest`, fournisseur complet seedé). Les `id`/timestamps sont illustratifs (run de test). Toute donnée affichée par le front DOIT apparaître dans ce JSON. Front #93→#96 peuvent démarrer.
|
||||
>
|
||||
> **2 constats validés à la capture** (cf. § 4.0.ter) :
|
||||
> 1. 🔧 **Fix ERP-92** : les réfs comptables (`tvaMode`/`paymentDelay`/`paymentType`/`bank`) sortaient en **IRI nu** (les entités partagées ne portaient que `client:read:accounting`, pas `supplier:read:accounting`). Corrigé → objet `{id, code, label}` embarqué (le front consultation/édition affiche le libellé sans fetch).
|
||||
> 2. ℹ️ **Liste « riche »** : le groupe `supplier:read` étant partagé liste+détail, la **collection embarque tout le bloc Information** (et, pour un user `accounting.view`, les scalaires compta + `ribs[]`). Comportement identique au M1 (groupe `client:read` partagé) — la datatable n'affiche que Nom/Catégories/Site(s)/MAJ, mais le payload est complet. Le gating `accounting` reste effectif (Commerciale ne voit ni compta ni `ribs` en liste comme en détail).
|
||||
|
||||
> **Forme d'enveloppe confirmée sur le M1 réel** (API Platform 4.2) : JSON-LD **sans préfixe `hydra:`** → clés `member` / `totalItems` / `view`, avec `@type: "Collection"` et `view.@type: "PartialCollectionView"`. `Content-Type: application/ld+json; charset=utf-8`. Pagination défaut 10 confirmée. Login réel = `POST /api/login_check` (nginx réécrit vers `/login_check`), réponse `204` + cookie HttpOnly `BEARER`.
|
||||
|
||||
`GET /api/suppliers` (liste, ADMIN) :
|
||||
`GET /api/suppliers?search=…` (liste, ADMIN — un membre) :
|
||||
```json
|
||||
{
|
||||
"@context": "/api/contexts/Supplier",
|
||||
"@id": "/api/suppliers",
|
||||
"@type": "Collection",
|
||||
"totalItems": 13,
|
||||
"totalItems": 1,
|
||||
"member": [
|
||||
{
|
||||
"@id": "/api/suppliers/1",
|
||||
"@id": "/api/suppliers/85",
|
||||
"@type": "Supplier",
|
||||
"id": 1,
|
||||
"companyName": "RECYCLA SAS",
|
||||
"id": 85,
|
||||
"companyName": "DOD59393F 862875",
|
||||
"categories": [
|
||||
{"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}
|
||||
{"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT",
|
||||
"categoryType": {"@id": "/api/category_types/602", "@type": "CategoryType", "id": 602, "code": "FOURNISSEUR", "label": "Fournisseur"},
|
||||
"createdAt": "…", "updatedAt": "…"}
|
||||
],
|
||||
"description": "Fournisseur de test complet.",
|
||||
"competitors": "Concurrent A, Concurrent B",
|
||||
"foundedAt": "2008-04-01T00:00:00+02:00",
|
||||
"employeesCount": 42,
|
||||
"revenueAmount": "1500000.00",
|
||||
"directorName": "Jean Dupont",
|
||||
"profitAmount": "120000.00",
|
||||
"volumeForecast": 8000,
|
||||
"siren": "123456789",
|
||||
"accountNumber": "F0001",
|
||||
"tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
|
||||
"nTva": "FR00123456789",
|
||||
"paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"},
|
||||
"paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
|
||||
"ribs": [
|
||||
{"@id": "/api/supplier_ribs/27", "@type": "SupplierRib", "id": 27, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "…", "updatedAt": "…"}
|
||||
],
|
||||
"createdAt": "…", "updatedAt": "…",
|
||||
"sites": [
|
||||
{"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"},
|
||||
{"@id": "/api/sites/2", "id": 2, "name": "Saint-Jean", "postalCode": "17400", "city": "Fontenet", "color": "#…"}
|
||||
{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
|
||||
{"@type": "Site", "@id": "/api/sites/88", "id": 88, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "fullAddress": "Z i\n17400 Fontenet"}
|
||||
],
|
||||
"updatedAt": "2026-02-17T09:30:00+00:00",
|
||||
"isArchived": false
|
||||
}
|
||||
],
|
||||
"view": {
|
||||
"@id": "/api/suppliers?page=1",
|
||||
"@type": "PartialCollectionView",
|
||||
"first": "/api/suppliers?page=1",
|
||||
"last": "/api/suppliers?page=2",
|
||||
"next": "/api/suppliers?page=2"
|
||||
}
|
||||
"view": {"@id": "/api/suppliers?search=…", "@type": "PartialCollectionView"}
|
||||
}
|
||||
```
|
||||
|
||||
> Les fournisseurs archivés sont **exclus** du `totalItems` (sur le M1, 14 clients en base → `totalItems: 13` car 1 archivé filtré par le Provider). `categories[]` (avec `code`/`name`) et `sites[]` (avec `name`/`postalCode` — **pas de `code`**) sont **embarqués** (cohérence M1/ERP-62, § 2.12) ; `sites` est l'agrégat dédoublonné des adresses via `Supplier::getSites()`. Fetch-joins repository obligatoires (anti N+1).
|
||||
> Les fournisseurs archivés sont **exclus** du `totalItems` (RG-2.17 — filtré par le Provider). `categories[]` (avec `code`/`name`) et `sites[]` (avec `name`/`postalCode` — **pas de `code`**) sont **embarqués** (cohérence M1/ERP-62, § 2.12) ; `sites` est l'agrégat dédoublonné des adresses via `Supplier::getSites()`. Fetch-joins repository (anti N+1) **vérifiés par test** (`SupplierListTest::testListQueryCountDoesNotGrowWithRowCount` : nombre de requêtes constant entre 2 et 4 fournisseurs). ⚠️ Le membre embarque aussi l'**Information complète** et — pour un user `accounting.view` (ici admin) — les **scalaires compta + `ribs[]`** (groupe `supplier:read` partagé liste/détail). Pour la **Commerciale** (sans `accounting.view`), `siren`/`tvaMode`/`paymentType`/`ribs`… **disparaissent** de chaque membre.
|
||||
|
||||
`GET /api/suppliers/1` (détail — user avec `accounting.view`) :
|
||||
`GET /api/suppliers/85` (détail — user avec `accounting.view`) :
|
||||
```json
|
||||
{
|
||||
"@id": "/api/suppliers/1",
|
||||
"@context": "/api/contexts/Supplier",
|
||||
"@id": "/api/suppliers/85",
|
||||
"@type": "Supplier",
|
||||
"id": 1,
|
||||
"companyName": "RECYCLA SAS",
|
||||
"id": 85,
|
||||
"companyName": "DOD59393F 862875",
|
||||
"categories": [
|
||||
{"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}
|
||||
{"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT",
|
||||
"categoryType": {"@id": "/api/category_types/602", "@type": "CategoryType", "id": 602, "code": "FOURNISSEUR", "label": "Fournisseur"}}
|
||||
],
|
||||
"description": "…", "competitors": "…", "foundedAt": "2008-04-01",
|
||||
"employeesCount": 42, "revenueAmount": "1500000.00", "directorName": "…",
|
||||
"profitAmount": "120000.00", "volumeForecast": 8000,
|
||||
"description": "Fournisseur de test complet.", "competitors": "Concurrent A, Concurrent B",
|
||||
"foundedAt": "2008-04-01T00:00:00+02:00", "employeesCount": 42, "revenueAmount": "1500000.00",
|
||||
"directorName": "Jean Dupont", "profitAmount": "120000.00", "volumeForecast": 8000,
|
||||
"siren": "123456789", "accountNumber": "F0001",
|
||||
"tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
|
||||
"nTva": "FR00123456789",
|
||||
"paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"},
|
||||
"paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
|
||||
"contacts": [
|
||||
{"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin",
|
||||
"jobTitle": "Responsable achats", "phonePrimary": "0612345678", "phoneSecondary": null,
|
||||
"email": "marie.martin@recycla.fr"}
|
||||
{"@id": "/api/supplier_contacts/39", "@type": "SupplierContact", "id": 39, "firstName": "Marie", "lastName": "Martin",
|
||||
"jobTitle": "Responsable achats", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"}
|
||||
],
|
||||
"addresses": [
|
||||
{"@id": "/api/supplier_addresses/1", "id": 1, "addressType": "DEPART",
|
||||
"country": "France", "postalCode": "86000", "city": "Poitiers",
|
||||
"street": "12 rue des Acacias", "streetComplement": null,
|
||||
{"@id": "/api/supplier_addresses/33", "@type": "SupplierAddress", "id": 33, "addressType": "DEPART",
|
||||
"country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
|
||||
"bennes": 3, "triageProvider": true,
|
||||
"sites": [{"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
|
||||
"categories": [{"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}],
|
||||
"contacts": [{"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin"}]}
|
||||
"sites": [
|
||||
{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"},
|
||||
{"@type": "Site", "@id": "/api/sites/88", "id": 88, "name": "Saint-Jean", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00"}
|
||||
],
|
||||
"contacts": [{"@id": "/api/supplier_contacts/39", "@type": "SupplierContact", "id": 39, "firstName": "Marie", "lastName": "Martin"}],
|
||||
"categories": [{"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT"}]}
|
||||
],
|
||||
"siren": "123456789", "accountNumber": "F0001",
|
||||
"tvaMode": {"@id": "/api/tva_modes/1", "id": 1, "label": "France (ventes)"},
|
||||
"nTva": "FR00123456789",
|
||||
"paymentDelay": {"@id": "/api/payment_delays/2", "id": 2, "label": "30 jours"},
|
||||
"paymentType": {"@id": "/api/payment_types/2", "id": 2, "code": "LCR", "label": "LCR"},
|
||||
"bank": null,
|
||||
"ribs": [
|
||||
{"@id": "/api/supplier_ribs/1", "id": 1, "label": "Compte principal",
|
||||
"bic": "SOGEFRPP", "iban": "FR7630003035400005000000123"}
|
||||
{"@id": "/api/supplier_ribs/27", "@type": "SupplierRib", "id": 27, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}
|
||||
],
|
||||
"isArchived": false, "archivedAt": null,
|
||||
"updatedAt": "2026-02-17T09:30:00+00:00"
|
||||
"isArchived": false
|
||||
}
|
||||
```
|
||||
|
||||
> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (pas `null` — réellement non sérialisées car le Provider retire le groupe). Le gating par **omission de clé** est confirmé confortable côté front. Le blame `updatedBy` est sérialisé en **IRI** (`"/api/me"` quand c'est l'user courant) — en tenir compte côté front.
|
||||
> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (pas `null` — réellement non sérialisées : le `SupplierReadGroupContextBuilder` n'ajoute pas le groupe). Gating par **omission de clé** confirmé sur le JSON réel (`SupplierSerializationContractTest::testRibsAbsentForCommercialeWithoutAccountingView` + `testAccountingScalarsGatedByOmission`). `bennes`/`triageProvider`/`addressType`/`addresses[].contacts` restent visibles (onglet Adresse non gaté). NB : ici `bank` est absent (paymentType=LCR sans banque) ; avec un VIREMENT, `bank` est embarqué `{id, code, label}` (fix ERP-92).
|
||||
|
||||
### 4.0.ter Pièges de sérialisation CONSTATÉS sur le M1 réel → parade M2 (OBLIGATOIRE)
|
||||
|
||||
@@ -1046,7 +1073,7 @@ Le M1 a subi un aller-retour (ERP-68) faute de fixtures alignées. Pour le M2, p
|
||||
|
||||
- [x] 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0)
|
||||
- [x] Décision embed vs GetCollection explicite et câblée (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5), **pas de POST-only**
|
||||
- [ ] **Réponses JSON RÉELLES** collées (§ 4.0.bis) — *en attente de `make start` + curl (DoD avant tickets front)*
|
||||
- [x] **Réponses JSON RÉELLES** collées (§ 4.0.bis) — capturées via PHPUnit (ERP-92, 2026-06-05) ; fix réfs compta IRI→{id,label} inclus
|
||||
- [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-2.16)
|
||||
- [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit, routes à plat : rappelés
|
||||
- [x] Réutilisations M1 identifiées (référentiels compta partagés, taxonomie code/type, `usePaginatedList`, blocs, archive, normalisation)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,339 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M3
|
||||
nom: "Répertoire prestataires"
|
||||
ecran: repertoire-prestataires
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0.2
|
||||
date_redaction: 2026-06-11
|
||||
# Historique :
|
||||
# V0.2 (2026-06-11) — Restitution Markdown du docx « M3-reportoire-prestataires.docx » (04/06/2026).
|
||||
# Alignement refonte-contact (comme M1/M2) : le contact principal inline du formulaire principal
|
||||
# du PDF V0.1 (Nom contact / Prénom contact / Téléphone + / Email) est RETIRÉ — saisie via
|
||||
# l'onglet Contacts uniquement (décision Matthieu, 11/06 : « oublie le contact inline, comme client »).
|
||||
# RG-3.01 / RG-3.02 (contact inline + max 2 tél sur le formulaire principal) supprimées en conséquence.
|
||||
# V0.1 (PDF) — version fonctionnelle plus ancienne, NON retenue (contact inline sur le formulaire principal).
|
||||
|
||||
# === LIENS ===
|
||||
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-42090&p=f&m=dev"
|
||||
regles_metier: [RG-3.03, RG-3.04, RG-3.05, RG-3.06, RG-3.07, RG-3.08, RG-3.09, RG-3.10, RG-3.11, RG-3.12, RG-3.13, RG-3.14, RG-3.15, RG-3.16, RG-3.17]
|
||||
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||
lien_spec_back: ./spec-back.md
|
||||
|
||||
# === VALIDATION CLIENT ===
|
||||
client_validation_1:
|
||||
statut: validee
|
||||
date: 2026-05-22
|
||||
version: V0
|
||||
valide_par: "Matthieu (CP MALIO)"
|
||||
client_validation_2:
|
||||
statut: validee
|
||||
date: 2026-06-01
|
||||
version: V0.1
|
||||
valide_par: "Matthieu (CP MALIO)"
|
||||
client_validation_3:
|
||||
statut: a_valider
|
||||
date: 2026-06-04
|
||||
version: V0.2
|
||||
resume: "Module 3 — Répertoire prestataires. Pôle Technique (nouvelle section sidebar). Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Contact / Adresse / Comptabilité (Rapports, Échanges = placeholders 'À venir'). PAS d'onglet Information. Sélecteur de site aussi sur le formulaire principal."
|
||||
trace_archivee: "uploads/M3-reportoire-prestataires.docx (V0.2) + M3-reportoire-prestataires-V01.pdf (V0.1, obsolète)"
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_taskgroup_id: 29 # M3 — Répertoire prestataires (projet STARSEED #6)
|
||||
lesstime_project_id: 6
|
||||
statut_global: en_dev
|
||||
---
|
||||
|
||||
# Module 3 — Répertoire prestataires (V0.2 front)
|
||||
|
||||
> **Origine** : spec fonctionnelle `M3-reportoire-prestataires.docx` (V0.2 du 04/06/2026 ; historique V0 22/05 → V0.1 01/06). Restitution Markdown pour intégration au workflow MALIO. Le contenu fonctionnel original n'est pas modifié, **sauf** l'alignement refonte-contact (cf. ci-dessous). Toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M3 réutilise massivement le pattern et les composants posés au [M1 clients](../M1-clients/spec-front.md) et au [M2 fournisseurs](../M2-suppliers/spec-front.md).
|
||||
|
||||
> **⚠️ Alignement refonte-contact (décision Matthieu, 11/06/2026)** : le PDF V0.1 portait un **contact principal inline** sur le formulaire principal (Nom du contact / Prénom du contact / Téléphone + bouton + / Email) avec RG-3.01 (Nom OU Prénom) et RG-3.02 (max 2 téléphones). Ce contact inline est **retiré**, exactement comme l'a fait M1/M2 (refonte-contact). Les coordonnées du contact se saisissent **uniquement dans l'onglet Contacts**. **RG-3.01 et RG-3.02 sont donc supprimées du formulaire principal** ; la garantie « au moins un contact nommé » est portée par RG-3.04 + RG-3.12, et le « maximum 2 téléphones » s'applique aux blocs Contact.
|
||||
|
||||
> **⚠️ Décision d'architecture (à confirmer) — pôle « Technique »** : le docx place le répertoire prestataires dans un **Module « Technique »**. Confirmé par Matthieu (11/06) : c'est bien un **nouveau pôle Technique**, distinct du Commercial. Côté front cela se traduit par une **nouvelle section sidebar « Technique »** (route `/providers`). Côté back, voir [`spec-back.md § 2.1`](./spec-back.md) (nouveau module `Technique`, entités jumelles du fournisseur, référentiels comptables consommés en relation ORM partagée).
|
||||
|
||||
## But
|
||||
|
||||
Lister tous les prestataires de l'organisation et accéder rapidement à leurs fiches : consultation, création, modification, archivage. C'est la **porte d'entrée du pôle Technique**.
|
||||
|
||||
## Accès
|
||||
|
||||
- **Depuis** : menu principal → section **Technique** → entrée « Répertoire prestataires » (route `/providers`).
|
||||
- **Rôles autorisés** (tableau « Rôles & permissions » du docx) :
|
||||
|
||||
| Rôle | Consultation | Création / Modification | Archivage |
|
||||
|---|---|---|---|
|
||||
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
|
||||
| **Bureau** | ✅ Tout | ✅ Tout sauf onglet Comptabilité | ❌ |
|
||||
| **Compta** | ✅ Tout | ✅ Onglet Comptabilité uniquement | ❌ |
|
||||
| **Commerciale** | ✅ Tout sauf Comptabilité | ✅ Tout sauf Comptabilité | ❌ |
|
||||
| **Usine** | ✅ Son site uniquement | — | ❌ |
|
||||
|
||||
> **Notes** :
|
||||
> - RBAC transposée sur `technique.providers.*` (cf. [`spec-back.md § 2.9 / § 5`](./spec-back.md)). Compta édite uniquement l'onglet Comptabilité d'un prestataire existant ; Compta ne peut pas **créer** un prestataire. **L'archivage est réservé à Admin**.
|
||||
> - **Cloisonnement par site (décision 11/06 — DANS LE PÉRIMÈTRE M3)** : « Tout » vs « son site uniquement » n'est **pas porté par le rôle** mais par l'**utilisateur**. Chaque user a un site courant ; **par défaut il ne voit que les prestataires rattachés à son site**. Les profils qui doivent voir tous les sites (Admin, et par défaut Bureau / Compta / Commerciale) ont la permission `sites.bypass_scope` (Admin l'a automatiquement). **Usine** n'a pas le bypass → cloisonnée à son site. Filtrage **automatique côté back** (cf. [`spec-back.md § 2.13`](./spec-back.md)) — aucun filtre à coder côté front.
|
||||
|
||||
## Navigation
|
||||
|
||||
Page d'entrée du pôle **Technique** (route `/providers`). Titre : « **Répertoire prestataires** ».
|
||||
|
||||
- Affichage principal : un **datatable** listant tous les prestataires **actifs** (les archivés sont masqués par défaut — toggle/filtre dédié).
|
||||
- **Clic sur une ligne** → écran **Consultation prestataire** (page dédiée).
|
||||
- **Bouton « + Ajouter »** (haut droite) → écran **Ajouter un prestataire**.
|
||||
- **Bouton « Filtrer »** (haut droite) → panneau de filtres (cf. ci-dessous).
|
||||
- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des prestataires **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md).
|
||||
|
||||
### Panneau de filtres (bouton « Filtrer »)
|
||||
|
||||
Réutilise le pattern M1/M2. Filtres branchés sur les query params de `GET /api/providers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) :
|
||||
|
||||
| Filtre | Composant | Query param back |
|
||||
|---|---|---|
|
||||
| **Recherche** (nom entreprise / contact / email) | `<MalioInputText>` | `?search=` |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi, type PRESTATAIRE) | `?categoryCode=` |
|
||||
| **Site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | `?siteId=` |
|
||||
| **Inclure les archivés** | `<MalioCheckbox>` | `?includeArchived=true` |
|
||||
|
||||
- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**), qui relance `GET /api/providers`.
|
||||
- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6).
|
||||
|
||||
## Datatable du Répertoire
|
||||
|
||||
Composant : `<MalioDataTable>` branché sur `usePaginatedList<Provider>({ url: '/providers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes (alignées M2) :
|
||||
|
||||
| Colonne | Source | Tri |
|
||||
|---|---|---|
|
||||
| **Nom** | `provider.companyName` | ASC par défaut |
|
||||
| **Catégories** | `provider.categories[].name` (embarquées en liste — cohérence M1/M2 ; libellé = `name`, pas `label`) | Non |
|
||||
| **Site** | `provider.sites[].name` (sites du prestataire — cf. note ci-dessous) | Non |
|
||||
| **Dernière activité** | `provider.updatedAt` (format `JJ-MM-AAAA`) — exposé dans `provider:read` | Oui |
|
||||
|
||||
> **Source de la colonne « Site »** : le M3 porte un sélecteur de site **sur le formulaire principal** (RG-3.03) — donc `provider.sites[]` est une relation **directe** du prestataire (≠ M2 où les sites venaient de l'agrégat des adresses). La colonne liste affiche ces sites directs. Voir [`spec-back.md § 2.12`](./spec-back.md).
|
||||
> **Clic sur une ligne** → écran Consultation. **Pagination** : standard Starseed 10 / 25 / 50 (défaut 10). Tri serveur `companyName ASC` par défaut.
|
||||
|
||||
## Écran « Ajouter un prestataire »
|
||||
|
||||
Création par **onglets successifs avec validation incrémentale** : pour passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant** ; les champs validés passent en lecture seule + bouton « Valider » désactivé (disabled). Cf. [`spec-back.md § 2.10`](./spec-back.md) (PATCH partiels par groupe de sérialisation).
|
||||
|
||||
**Accès** : bouton « + Ajouter » du Répertoire. **Rôles** : Admin, Bureau.
|
||||
|
||||
**Barre d'onglets en création (3 onglets)** : `Contact` · `Adresse` · `Comptabilité`. Les onglets `Rapports` et `Échanges` **n'apparaissent PAS dans le flux de création** — ils ne sont présents qu'en Consultation / Modification (placeholders « À venir »).
|
||||
|
||||
> **Différence majeure avec M2 : PAS d'onglet « Information ».** Le M3 n'a aucun champ Description / Concurrent / Date création / Salariés / CA / Dirigeant / Résultat / Volume. Le formulaire principal est minimal (3 champs).
|
||||
|
||||
> **Règle « placeholder par défaut » (convention MALIO)** : tout onglet ou écran que la spec ne détaille pas explicitement (ici **Rapports** et **Échanges**) est livré en **placeholder « À venir »** (frame vide, navigable, pas de validation ni d'API), à l'identique des autres modules (M1/M2). Aucun champ inventé hors spec.
|
||||
|
||||
### Formulaire principal (pré-onglets)
|
||||
|
||||
1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/providers`, puis bascule sur l'onglet Contact ; les champs passent en readonly.
|
||||
|
||||
| Champ | Type composant | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom du prestataire (Entreprise)** | `<MalioInputText>` | Oui | RG-3.11 (UPPERCASE serveur) ; RG-3.10 (unicité) |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | `Category` de **type PRESTATAIRE** via `GET /api/categories?typeCode=PRESTATAIRE` (RG-3.09). Libellé affiché = `category.name`. |
|
||||
| **Sélecteur de site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | Oui | RG-3.03 — ≥ 1 site. Les 3 cases = les 3 `Site` fixes ; libellés « 86/17/82 » = **préfixe du `postalCode`** (86100 / 17400 / 82400), pas un `Site.code` (qui n'existe pas). La sélection stocke des **IDs de Site** (M2M `provider_site`). |
|
||||
|
||||
**Action** : « Valider » (`<MalioButton>`) → POST `/api/providers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Contact ».
|
||||
|
||||
### Onglet « Contact »
|
||||
|
||||
Saisir un ou plusieurs contacts. Au moins un bloc Contact valide est requis (RG-3.12). **(Refonte-contact : pas de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)**
|
||||
|
||||
**Bloc Contact** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom** | `<MalioInputText>` | Conditionnel | RG-3.04 + RG-3.11 (Capitalize) |
|
||||
| **Prénom** | `<MalioInputText>` | Conditionnel | RG-3.04 + RG-3.11 (Capitalize) |
|
||||
| **Fonction** | `<MalioInputText>` | Non | — |
|
||||
| **Téléphone** (x1, +1 possible, **max 2**) | `<MalioInputText>` | Non | RG-3.11 (format) ; max 2 téléphones par contact |
|
||||
| **Email** | `<MalioInputText>` type email | Non | RG-3.11 (lowercase) |
|
||||
|
||||
**RG-3.04 / RG-3.12** : un bloc Contact est valide dès qu'au moins 1 champ est rempli ; au moins 1 bloc Contact valide pour finaliser l'onglet — l'onglet Contact ne peut pas être validé vide.
|
||||
|
||||
**Actions** :
|
||||
- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a pas au moins 1 champ rempli** (RG-3.04).
|
||||
- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc.
|
||||
- « Valider » → PATCH `/api/providers/{id}/contacts`.
|
||||
|
||||
### Onglet « Adresse »
|
||||
|
||||
Saisir une ou plusieurs adresses, rattachées à un ou plusieurs sites (86 / 17 / 82) et à des contacts.
|
||||
|
||||
**Bloc Adresse** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Sélecteur de site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | Oui | RG-3.05 — ≥ 1 site. Stocke des IDs de Site (M2M `provider_address_site`). |
|
||||
| **Adresse** | `<MalioInputText>` (saisie assistée) | Oui | RG-3.06 — autocomplete BAN |
|
||||
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
|
||||
| **Code postal** | `<MalioInputText>` (saisie assistée) | Oui | RG-3.06 — déclenche autocomplete ville (BAN) |
|
||||
| **Ville** | `<MalioSelect>` (saisie assistée) | Oui | RG-3.06 — alimentée par api-adresse.data.gouv.fr suivant le CP ; si plusieurs villes, choix dans le select |
|
||||
| **Pays** | `<MalioSelect>` (préremplie « France ») | Oui | — |
|
||||
| **Catégories** | `<MalioSelectCheckbox>` (multi) | Oui | Catégories de type PRESTATAIRE (RG-3.09) |
|
||||
| **Contact** | `<MalioSelectCheckbox>` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
|
||||
|
||||
> **Différence avec M2** : l'adresse prestataire n'a **PAS** de Type d'adresse (Prospect/Départ/Rendu), **PAS** de Bennes, **PAS** de Prestation de triage. C'est une adresse « simple » (site + adresse postale + catégories + contacts).
|
||||
|
||||
**Actions** :
|
||||
- « + Nouvelle Adresse » : ajoute un bloc identique au premier.
|
||||
- « Supprimer » (icône) : modal de confirmation puis suppression.
|
||||
- « Valider » → PATCH `/api/providers/{id}/addresses`.
|
||||
|
||||
### Onglet « Comptabilité »
|
||||
|
||||
⚠ **Accessible aux rôles avec `technique.providers.accounting.view`** (Admin + Compta). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer** cet onglet (`accounting.manage`). Compta ne peut pas créer un prestataire (pas de `manage` global).
|
||||
|
||||
**Champs comptables** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **SIREN** | `<MalioInputText>` (masque 9 chiffres) | Oui | 9 chiffres. **Pas d'unicité** (cf. [`spec-back.md § 2.6`](./spec-back.md)) |
|
||||
| **Numéro de compte** | `<MalioInputText>` | Oui | — |
|
||||
| **Mode de TVA** | `<MalioSelect>` | Oui | Liste depuis `/api/tva_modes` (référentiel partagé M1) |
|
||||
| **N° de TVA** | `<MalioInputText>` | Oui | — |
|
||||
| **Délai de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_delays` |
|
||||
| **Type de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_types` |
|
||||
| **Banque** | `<MalioSelect>` | Conditionnel | RG-3.07 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks` (SG / CIC / CA). |
|
||||
|
||||
**Bloc RIB** (0..n, présence obligatoire conditionnée par RG-3.08) :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Libellé** | `<MalioInputText>` | Oui (si LCR) | RG-3.08 |
|
||||
| **BIC** | `<MalioInputText>` | Oui (si LCR) | RG-3.08 |
|
||||
| **IBAN** | `<MalioInputText>` | Oui (si LCR) | RG-3.08 |
|
||||
|
||||
**Actions** :
|
||||
- « + RIB » : ajoute un bloc.
|
||||
- « Supprimer » (icône) : modal de confirmation.
|
||||
- « Valider » → PATCH `/api/providers/{id}` (groupe `provider:write:accounting`) + sous-ressource RIBs.
|
||||
|
||||
## Écran « Consultation prestataire »
|
||||
|
||||
Tous les champs en **lecture seule**. La page s'ouvre par défaut sur l'onglet **Contacts**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans `+` pour ajouter des blocs.
|
||||
|
||||
- **Flèche retour** (gauche) → revient au Répertoire.
|
||||
- **Bouton « Modifier »** (droite, visible si `technique.providers.manage`) → écran Modification.
|
||||
- **Bouton « Archiver »** (droite, visible **uniquement Admin** via `technique.providers.archive`) → modal de confirmation, puis PATCH `/api/providers/{id}` `{ "isArchived": true }`.
|
||||
|
||||
> Un prestataire archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé.
|
||||
|
||||
### Onglets affichés en consultation
|
||||
|
||||
`Contacts` · `Adresse` · `Rapports` · `Échanges` · `Comptabilité`. Navigation **libre** entre onglets (pas de séquence forcée). `Rapports` et `Échanges` = placeholders « À venir ». `Comptabilité` selon permission.
|
||||
|
||||
- **Onglet Contacts** : un bloc par contact, 5 champs en lecture seule (Nom / Prénom / Fonction / Téléphone / Email).
|
||||
- **Onglet Adresse** : un bloc par adresse, en lecture seule (Sélecteur de site / Adresse / Adresse complémentaire / Code postal / Ville / Pays / Catégorie / Contact).
|
||||
- **Onglet Comptabilité** : bloc principal (champs comptables) + un bloc par RIB. Le champ **Banque** n'apparaît que si Type de règlement = Virement (RG-3.07).
|
||||
|
||||
## Écran « Modification prestataire »
|
||||
|
||||
Comportement identique à l'écran Ajouter (mêmes formulaires, mêmes RG-3.03 → RG-3.08) sauf :
|
||||
- **Pas de formulaire principal** réaffiché (champs principaux édités via l'onglet correspondant / pré-remplis).
|
||||
- Les champs sont **pré-remplis** avec les valeurs actuelles du prestataire.
|
||||
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
|
||||
- Les onglets pour lesquels l'utilisateur n'a **pas** la permission `manage` (ou `accounting.manage`) restent en **lecture seule** (pas de bouton Valider, pas d'icône suppression).
|
||||
- **Accès** : Admin, Bureau (Compta pour l'onglet Comptabilité uniquement).
|
||||
|
||||
## Composants UI à utiliser (`@malio/layer-ui`)
|
||||
|
||||
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
|
||||
- **Input texte** : `<MalioInputText>`
|
||||
- **Select simple** : `<MalioSelect>` (Pays, Ville, référentiels comptables)
|
||||
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (Catégorie, Sites, Contacts rattachés)
|
||||
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
|
||||
- **Toasts** : standards via `useApi()`
|
||||
- **Validation par champ** : `useFormErrors` (mapping 422 inline — règle frontend obligatoire)
|
||||
|
||||
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
|
||||
- Modal de confirmation : `<MalioModal>` ou wrapper partagé dans `frontend/shared/` (réutiliser celui du M1/M2).
|
||||
|
||||
## Composables & appels API
|
||||
|
||||
- `usePaginatedList<Provider>({ url: '/providers' })` — liste paginée (obligatoire). La liste consomme `categories[]` (libellé = `name`) et `sites[]` (libellé = `name`, pas de `code`) **embarqués** + `updatedAt` (cf. [`spec-back.md § 2.12 / § 4.0`](./spec-back.md)).
|
||||
- `useProvider(id)` — charge le détail via `GET /api/providers/{id}`, qui **embarque** `contacts`, `addresses` (avec `sites` / `categories` / `contacts` imbriqués) et, si permission, `ribs` + scalaires compta. Écrans Consultation et Modification peuplés depuis cette seule réponse (RETEX M1 §2 : embed borné, pas de N+1). **DoD avant intégration** : vérifier que le JSON réel contient ces blocs (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)).
|
||||
- `useProviderForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useSupplierForm()`.
|
||||
- `useAddressAutocomplete()` — **réutilisé du M1/M2** (BAN), pas de réécriture.
|
||||
- `usePermissions()` — masque l'onglet Comptabilité et le bouton Archiver.
|
||||
- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4).
|
||||
- Filter `formatPhoneFR()` — **réutilisé** pour l'affichage `XX XX XX XX XX`.
|
||||
|
||||
## Règles de formatage et normalisation
|
||||
|
||||
Le serveur normalise systématiquement (RG-3.11 — cf. [`spec-back.md`](./spec-back.md)) :
|
||||
|
||||
| Champ | Normalisation serveur | Affichage front |
|
||||
|---|---|---|
|
||||
| Nom prestataire (`companyName`) | UPPERCASE intégral | UPPERCASE |
|
||||
| Nom + Prénom contact | Capitalize | identique |
|
||||
| Téléphones (blocs `ProviderContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` (filter Vue) |
|
||||
| Email | lowercase intégral | identique |
|
||||
|
||||
> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur normalisée que l'UI affiche.
|
||||
|
||||
## API adresse postale
|
||||
|
||||
Code postal + Ville + Adresse branchés sur **api-adresse.data.gouv.fr** (BAN) via le composable `useAddressAutocomplete()` **déjà créé au M1/M2** (réutilisé tel quel) :
|
||||
- À la saisie du CP (5 chiffres) : `GET https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville (RG-3.06 : si plusieurs villes, choix dans le select).
|
||||
- À la saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions.
|
||||
- Cas dégradé (timeout / offline) : Ville en `<MalioInputText>` libre + toast d'avertissement.
|
||||
|
||||
## Différences notables avec le M2 (fournisseurs)
|
||||
|
||||
| Zone | M2 fournisseurs | M3 prestataires |
|
||||
|---|---|---|
|
||||
| Onglet Information | 8 champs (Description … Volume) | **Absent** (aucun champ Information) |
|
||||
| Sélecteur de site sur formulaire principal | Non (sites uniquement via adresses) | **Oui** (RG-3.03 — relation directe `provider.sites`) |
|
||||
| Type d'adresse | Radio Prospect / Départ / Rendu (RG-2.09) | **Absent** |
|
||||
| Bennes / Prestation de triage (adresse) | Présents | **Absents** |
|
||||
| Onglet Transport | Placeholder | **Absent** |
|
||||
| Onglet Statistiques | Placeholder | **Absent** |
|
||||
| Onglets « À venir » | Transport / Stats / Rapports / Échanges | **Rapports / Échanges** uniquement |
|
||||
| Catégories | type `FOURNISSEUR` | **nouveau type `PRESTATAIRE`** |
|
||||
| Pôle / module | Commercial | **Technique** (nouvelle section sidebar + module back) |
|
||||
| Cloisonnement par site | aucun | **Visibilité par site, pilotée par l'utilisateur** (bypass via `sites.bypass_scope`) — § 2.13 |
|
||||
|
||||
## Points résolus côté back
|
||||
|
||||
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|
||||
|---|---|---|
|
||||
| 1 | Catégorie multi-select | M2M `provider_category`, `Category` de type **PRESTATAIRE** (RG-3.09) |
|
||||
| 2 | Site sur le formulaire principal | M2M `provider_site` (≥ 1 — RG-3.03), distinct de `provider_address_site` (RG-3.05) |
|
||||
| 3 | Onglet Comptabilité : qui édite ? | Admin + Compta (`accounting.manage`) ; Bureau/Commerciale ne le voient pas |
|
||||
| 4 | Workflow par onglet | Sauvegarde incrémentale (POST principal + PATCH partiels) — pas d'état « draft » |
|
||||
| 5 | Onglets « À venir » | Placeholder minimal « À venir » (Rapports / Échanges) |
|
||||
| 6 | Archive vs delete | Flag `is_archived` séparé de `deleted_at` ; archivage Admin seul ; soft delete = HP |
|
||||
| 7 | Unicité métier | Nom de prestataire uniquement (à valider — § 2.6). SIREN/email non uniques |
|
||||
| 8 | Référentiels comptables | Réutilisés M1/M2 (zéro duplication) ; relation ORM partagée |
|
||||
| 9 | API code postal | BAN via `useAddressAutocomplete()` du M1/M2 (RG-3.06) |
|
||||
| 10 | Format export | XLSX uniquement (CSV = HP) |
|
||||
| 11 | Cloisonnement par site (Usine « son site ») | Filtre back automatique par `currentSite` + bypass `sites.bypass_scope` (§ 2.13 / RG-3.17) |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tickets Lesstime
|
||||
|
||||
**TaskGroup Lesstime** : **#29 — M3 — Répertoire prestataires** (projet `ERP / Starseed`, projectId=6) — créé le 11/06/2026, 16 tickets `ERP-131` → `ERP-146`, statut « Prêt à dev », assignés à **Tristan**.
|
||||
|
||||
| # | Ticket | Réf | Tag |
|
||||
|---|---|---|---|
|
||||
| 1.1 | Créer module Technique + taxonomie PRESTATAIRE | ERP-131 | Backend |
|
||||
| 1.2 | Migrer le schéma BDD M3 (provider + sous-collections) | ERP-132 | Backend |
|
||||
| 1.3 | Créer entités + repositories Provider* | ERP-133 | Backend |
|
||||
| 1.4 | ProviderProvider + ProviderProcessor + cloisonnement site | ERP-134 | Backend |
|
||||
| 1.5 | Sous-ressources Contacts / Adresses / RIBs | ERP-135 | Backend |
|
||||
| 1.6 | Valider les RG métier server-side (RG-3.03→3.09) | ERP-136 | Backend |
|
||||
| 1.7 | Export XLSX des prestataires | ERP-137 | Backend |
|
||||
| 1.8 | RBAC technique.providers.* (3 sources) | ERP-138 | Backend |
|
||||
| 1.9 | PHPUnit RG-3.x + capture contrat JSON | ERP-139 | Backend |
|
||||
| 1.10 | Page Répertoire (/providers) | ERP-140 | Frontend |
|
||||
| 1.11 | Page Ajouter (/providers/new) + formulaire principal | ERP-141 | Frontend |
|
||||
| 1.12 | Onglet Contact | ERP-142 | Frontend |
|
||||
| 1.13 | Onglet Adresse (autocomplete BAN) | ERP-143 | Frontend |
|
||||
| 1.14 | Onglet Comptabilité + RIB | ERP-144 | Frontend |
|
||||
| 1.15 | Pages Consultation + Modification | ERP-145 | Frontend |
|
||||
| 1.16 | i18n + sidebar Technique + libellés audit | ERP-146 | Frontend |
|
||||
|
||||
> Détail back complet → voir [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper).
|
||||
@@ -0,0 +1,100 @@
|
||||
# M4 — Plan maître worktrees (back, Matthieu)
|
||||
|
||||
> **Rôle de ce fichier** : vue d'ensemble que la *conversation maître* tient à jour.
|
||||
> Chaque worktree = une conversation Claude isolée + une branche + une PR vers `develop`.
|
||||
> Les prompts à coller sont dans `WT*.md`.
|
||||
|
||||
## Principe
|
||||
|
||||
- 1 worktree = 1 branche partant de `origin/develop` (à jour des deps).
|
||||
- 1 ticket = 1 PR atomique vers **`develop`** (jamais `main`).
|
||||
- Commit autorisé sur la branche du worktree (ces prompts SONT la demande explicite) ;
|
||||
`git commit --no-verify` OK si `make test` est déjà vert (le hook relance toute la suite).
|
||||
- **Chaque worktree ouvre SA PR** vers `develop` en fin de tâche (cf. bloc PR ci-dessous).
|
||||
|
||||
## Bloc PR standard (repris dans chaque prompt)
|
||||
|
||||
```bash
|
||||
git push -u origin <branche>
|
||||
tea pr create --base develop --head <branche> \
|
||||
--title "<type>(<scope>) : <titre>" \
|
||||
--description "Résumé + lien ticket Lesstime ERP-XXX"
|
||||
```
|
||||
Puis **labelliser la PR via l'API Gitea** (tea ne pose pas les labels en CLI — `gitea.malio.fr`).
|
||||
Cible **`develop`**, jamais `main`. **Aucune mention de Claude/IA** dans titre ou description.
|
||||
|
||||
## Vagues & ordre de merge
|
||||
|
||||
```
|
||||
VAGUE 0 (en parallèle, dès maintenant)
|
||||
WT1 1.2 upload Shared base: origin/develop ──┐
|
||||
WT2 1.1 RBAC + sidebar base: origin/develop (≥ERP-150) ──┤ indépendants
|
||||
│
|
||||
VAGUE 1 (critique, séquentiel) │
|
||||
WT3 1.3 migration + 1.5 entités/resource/provider + i18n audit
|
||||
base: origin/develop APRÈS merge WT1 (FK uploaded_document)
|
||||
⭐ livre le CONTRAT JSON liste+détail → débloque le front (Tristan)
|
||||
|
||||
VAGUE 2 (fan-out, tous en parallèle dès WT3 mergé)
|
||||
WT4 1.6 processor base: develop ≥ WT3
|
||||
WT5 1.4 qualimat endpoint base: develop ≥ WT2 (perm) + ERP-39 (indépendant de WT3)
|
||||
WT6 1.7 adresses base: develop ≥ WT3
|
||||
WT7 1.8 contacts base: develop ≥ WT3
|
||||
WT8 1.9 prix base: develop ≥ WT3
|
||||
WT9 1.10 export XLSX base: develop ≥ WT3
|
||||
|
||||
VAGUE 3 (final)
|
||||
WT10 1.11 tests + fixtures + contrat base: develop ≥ TOUT
|
||||
```
|
||||
|
||||
**Parallélisme réel** : 2 worktrees en V0, puis 1 goulot (WT3), puis **jusqu'à 6 en V2**, puis 1 (WT10).
|
||||
|
||||
## Règle anti-conflit worktree (IMPORTANT)
|
||||
|
||||
Pour que WT4→WT9 tournent en parallèle sans conflit de merge :
|
||||
|
||||
| Fichier partagé | Qui le touche | Les autres |
|
||||
|---|---|---|
|
||||
| `CarrierFixtures` | **WT10 uniquement** | interdit (WT3 met un fixture minimal, WT6-9 n'y touchent pas) |
|
||||
| Entité `Carrier` (ApiResource) | **WT3** crée, **WT4** ajoute le Processor | WT6-9 créent des **resources/processors dédiés** par sous-entité, ne modifient pas `Carrier` |
|
||||
| `ColumnCommentsCatalog` | WT1 (`uploaded_document`), WT3 (`carrier*`) | personne d'autre |
|
||||
| `fr.json` (clés audit) | **WT3** (clés `audit.entity.transport_*`) | personne d'autre côté back |
|
||||
| `migrations/` | WT1 puis WT3 (ordre timestamp) | aucune autre migration |
|
||||
|
||||
## Mode retenu : STACK séquentiel, SANS worktree (repo principal)
|
||||
|
||||
Matthieu empile les MR, un ticket à la fois, **directement dans `/home/matthieu/dev_malio/Starseed`** (pas de worktree).
|
||||
- **Ignorer les blocs `git worktree add` des `WT*.md`** → remplacés par une branche normale :
|
||||
```bash
|
||||
git fetch origin
|
||||
git checkout -b feat/erp-XXX-... origin/<branche-précédente>
|
||||
```
|
||||
- **WT1 hors pile** (déjà mergé). Pile M4 — chaque branche basée sur la précédente :
|
||||
`WT2 → WT3 → WT4 → WT5 → WT6 → WT7 → WT8 → WT9 → WT10`
|
||||
- PR de chaque maillon : `--base <branche-précédente>` (bas de pile WT2 = `develop`). Au merge, les MR du dessus se recible auto.
|
||||
- Docker tourne sur le repo principal → `make test`/`php-cs-fixer` OK sans rebind (le piège worktree-vs-mount ne s'applique plus).
|
||||
- Worktrees créés pour WT1/WT2 à nettoyer : `git worktree remove ../sb-erp154-upload ../sb-erp153-rbac`.
|
||||
- Garder les MR basses propres ; merger dans l'ordre.
|
||||
|
||||
## Suivi (tenu par la conv maître)
|
||||
|
||||
| WT | Ticket | ERP | État | PR | Notes |
|
||||
|----|--------|-----|------|----|----|
|
||||
| WT1 | 1.2 upload | 154 | ✅ MERGÉ | #108 | migration `Version20260615130000` |
|
||||
| WT2 | 1.1 RBAC | 153 | ✅ PR ouverte | #111 | bas de pile (cible develop) |
|
||||
| WT3 | 1.3+1.5 | 155+157 | ▶️ À LANCER | — | stack sur `feat/erp-153-rbac` ; gate contrat front |
|
||||
| WT4 | 1.6 proc | 158 | ⛔ bloqué par WT3 | — | |
|
||||
| WT5 | 1.4 qualimat | 156 | ⛔ bloqué par WT2+ERP-39 | — | |
|
||||
| WT6 | 1.7 adresses | 159 | ⛔ bloqué par WT3 | — | |
|
||||
| WT7 | 1.8 contacts | 160 | ⛔ bloqué par WT3 | — | |
|
||||
| WT8 | 1.9 prix | 161 | ⛔ bloqué par WT3 | — | |
|
||||
| WT9 | 1.10 export | 162 | ⛔ bloqué par WT3 | — | |
|
||||
| WT10 | 1.11 tests | 163 | ⛔ bloqué par tout | — | |
|
||||
|
||||
## Cadre commun à tous les prompts (rappels projet)
|
||||
|
||||
- Carrier vit dans `src/Module/Transport/` (créé par ERP-150). **Miroir = `src/Module/Commercial/`** (Supplier).
|
||||
- Tests sous `tests/Module/Transport/Api/` (miroir `tests/Module/Commercial/Api/`).
|
||||
- `declare(strict_types=1);` partout ; commentaires **FR**, code EN.
|
||||
- `make test` + `make php-cs-fixer-allow-risky` avant de dire « fini ».
|
||||
- Ne jamais mentionner Claude/IA dans commit/PR.
|
||||
@@ -0,0 +1,44 @@
|
||||
# WT1 — Infra upload générique `Shared` (ticket 1.2 / ERP-154)
|
||||
|
||||
> Créer le worktree puis lancer Claude dedans :
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp154-upload -b feat/erp-154-upload origin/develop
|
||||
> cd ../sb-erp154-upload && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` (aucune dépendance — peut démarrer tout de suite, même avant le merge du socle Transport).
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Tu travailles sur le projet Starseed (modular monolith DDD, Symfony 8 / API Platform 4). Lis `CLAUDE.md` et `.claude/rules/backend.md` avant de coder. Charge le skill `backend-entity-conventions`.
|
||||
|
||||
**Mission** : poser une infra d'upload de fichiers **générique et réutilisable** dans `src/Shared/` (la « Décharge » du M4 en sera le 1er consommateur, mais ce ticket ne touche PAS au module Transport).
|
||||
|
||||
**Spec** : `docs/specs/M4-transporteurs/spec-back.md § 2.7`.
|
||||
|
||||
**À livrer** :
|
||||
1. Table `uploaded_document` (migration namespace racine `DoctrineMigrations` dans `migrations/`, postérieure à la dernière présente — vérifie `ls migrations/`). Colonnes : `id`, `original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum`, `created_at`, `created_by`.
|
||||
2. Service `Shared\Infrastructure\Upload\FileUploader` :
|
||||
- validation MIME **server-side via `$file->getMimeType()`** (JAMAIS `getClientMimeType()`),
|
||||
- whitelist MIME explicite (PDF + images),
|
||||
- bornage taille, checksum sha256, écriture disque `var/uploads/{yyyy}/{mm}/`.
|
||||
3. Endpoint `POST /api/uploaded_documents` (multipart) → renvoie l'IRI. MIME hors whitelist → **422**.
|
||||
|
||||
**Gardes-fous (cassent `make test` sinon)** :
|
||||
- **`COMMENT ON COLUMN` sur TOUTES les colonnes** de `uploaded_document` (FR, ≤200 car., règle n°12) ET ajoute le bloc `'uploaded_document' => [...]` dans `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php` — sinon `make test-db-setup` drope les COMMENT et `ColumnsHaveSqlCommentTest` casse.
|
||||
- Pagination : si tu exposes une `GetCollection`, elle reste paginée (`CollectionsArePaginatedTest`).
|
||||
|
||||
**Scope STRICT** : uniquement `src/Shared/` + migration + catalog. Ne crée AUCUN fichier sous `src/Module/Transport/`. Pas d'antivirus/S3/purge (hors périmètre, § 9).
|
||||
|
||||
**Tests à écrire** (PHPUnit) : MIME hors whitelist → 422 ; MIME valide → IRI + ligne persistée + checksum calculé.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky` propre. Commit (`--no-verify` OK si `make test` déjà vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-154-upload
|
||||
tea pr create --base develop --head feat/erp-154-upload \
|
||||
--title "feat(shared) : infra upload générique (ERP-154)" \
|
||||
--description "Table uploaded_document + FileUploader + endpoint POST. Ticket ERP-154."
|
||||
```
|
||||
Puis labellise la PR via l'API Gitea (tea ne pose pas les labels en CLI). Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,37 @@
|
||||
# WT10 — Tests PHPUnit + fixtures + contrat JSON (ticket 1.11 / ERP-163)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp163-tests -b feat/erp-163-carrier-tests origin/develop
|
||||
> cd ../sb-erp163-tests && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de TOUS les worktrees back** (WT1→WT9). C'est le filet final.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`, `.claude/rules/testing.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `tests/Module/Commercial/Api/Supplier*Test.php`.
|
||||
|
||||
**Mission** : couverture complète des RG + capture du contrat de sérialisation + fixtures consolidées. C'est le DoD back avant intégration front.
|
||||
|
||||
**Spec** : `spec-back.md § 4.0.bis / 8.1 / 8.4`.
|
||||
|
||||
**À livrer** :
|
||||
- Matrice **RG-4.01→4.14** couverte (§ 8.1) + RBAC par rôle (Compta/Usine → 403, Commerciale → 403 sur write, Admin → archive).
|
||||
- `CarrierSerializationContractTest` : capture JSON réel **liste + détail** ; `prices[].client`/`.supplier`/sites **embarqués** (pas IRI) ; `qualimatCarrier` embarqué ; `isArchived` présent. Colle les JSON dans `spec-back.md § 4.0.bis`.
|
||||
- Anti-N+1 liste ; pagination Hydra ; audit (`entity_type='Carrier'`) ; `AuditableEntitiesHaveI18nLabelTest` vert.
|
||||
- **`CarrierFixtures` idempotent (§ 8.4)** — c'est ICI que les fixtures complètes vivent : transporteur QUALIMAT (validité passée → RG-4.04), AUTRE+décharge, affrété, LIOT, complet (contacts/adresses/prix CLIENT+FOURNISSEUR), 1 archivé.
|
||||
|
||||
**Piège CI (mémoire projet)** : la CI tourne `APP_DEBUG=0`. Les tests de **comptage de requêtes (anti-N+1)** passent en local mais cassent en CI (DoctrineDataHolder absent) → vérifie/active `profiling: true` dans la config Doctrine de l'environnement `test`. Sans ça le test anti-N+1 sera rouge en CI.
|
||||
|
||||
**Scope** : tests + `CarrierFixtures` + remplissage § 4.0.bis. Tu peux ajuster un test cassé hérité d'un autre WT mais signale-le à la conv maître (ne masque pas un vrai bug).
|
||||
|
||||
**Fini quand** : `make test` **intégralement vert** + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-163-carrier-tests
|
||||
tea pr create --base develop --head feat/erp-163-carrier-tests \
|
||||
--title "test(transport) : couverture RG-4.01→4.14 + contrat + fixtures (ERP-163)" \
|
||||
--description "Matrice RG + CarrierSerializationContractTest + CarrierFixtures + § 4.0.bis. Ticket ERP-163."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,45 @@
|
||||
# WT2 — Permissions `transport.carriers.*` + sidebar (ticket 1.1 / ERP-153)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp153-rbac -b feat/erp-153-rbac origin/develop
|
||||
> cd ../sb-erp153-rbac && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge d'ERP-150** (le module `Transport` doit exister). Vérifie : `ls src/Module/Transport/`.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (modular monolith DDD). Lis `CLAUDE.md`, `.claude/rules/architecture.md` et `.claude/rules/testing.md` avant de coder.
|
||||
|
||||
**Mission** : poser le socle RBAC du module Transport et son entrée de menu. `TransportModule::permissions()` renvoie `[]` aujourd'hui.
|
||||
|
||||
**Spec** : `spec-back.md § 5` + `spec-front.md § Accès`.
|
||||
|
||||
**À livrer** :
|
||||
1. `TransportModule::permissions()` déclare `transport.carriers.view`, `transport.carriers.manage`, `transport.carriers.archive`. `app:sync-permissions` les enregistre.
|
||||
2. **Matrice § 5.2** : Admin (view+manage+archive), Bureau (view+manage), Commerciale (view), Compta + Usine (**aucune**).
|
||||
3. **RÈGLE ABSOLUE n°8 — les 3 sources RBAC dans le MÊME commit** :
|
||||
- `config/sidebar.php` : section « Transport » + item `/carriers` + `permission: transport.carriers.view`,
|
||||
- `frontend/tests/e2e/_fixtures/personas.ts` : ajuster `permissions` + `expectedAdminLinks` des personas existants,
|
||||
- `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` : miroir back des mêmes personas.
|
||||
4. Item sidebar masqué pour Compta/Usine ; visible Admin/Bureau/Commerciale.
|
||||
|
||||
**Pièges** :
|
||||
- Ne touche QUE le RBAC/sidebar — pas d'entité, pas de migration.
|
||||
- Toute modif d'une seule des 3 sources sans les 2 autres = drift / test cassé.
|
||||
- Section « Transport » vs « Logistique » : prends « Transport » (cosmétique, alignable plus tard).
|
||||
|
||||
**Tests à écrire/vérifier** : `app:sync-permissions` OK ; cohérence personas (pas de drift). Lance `make test`.
|
||||
|
||||
**Scope STRICT** : RBAC + sidebar + 3 miroirs. Rien d'autre.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si test vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-153-rbac
|
||||
tea pr create --base develop --head feat/erp-153-rbac \
|
||||
--title "feat(transport) : permissions carriers + sidebar (ERP-153)" \
|
||||
--description "RBAC transport.carriers.* + 3 sources RBAC alignées. Ticket ERP-153."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,54 @@
|
||||
# WT3 ⭐ — Migration + entités Carrier* + ApiResource + Provider (tickets 1.3 + 1.5 / ERP-155 + ERP-157)
|
||||
|
||||
> **Worktree pivot : il livre le CONTRAT JSON qui débloque tout le front.**
|
||||
> **Mode STACK, sans worktree** (repo principal) — base = branche de WT2 :
|
||||
> ```bash
|
||||
> cd /home/matthieu/dev_malio/Starseed && git fetch origin
|
||||
> git checkout -b feat/erp-155-carrier-schema-entities origin/feat/erp-153-rbac
|
||||
> ```
|
||||
> **Base** : `feat/erp-153-rbac` (contient ERP-150 + WT1 + RBAC WT2). Quand #111 sera mergé dans develop, la PR de WT3 se recible automatiquement sur develop.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (modular monolith DDD, Symfony 8 / API Platform 4). Lis `CLAUDE.md`, `.claude/rules/backend.md`, `.claude/rules/architecture.md`. **Charge le skill `backend-entity-conventions`** (patterns entités/migrations complets).
|
||||
|
||||
**Mission** : créer le schéma BDD du répertoire transporteurs + les entités + le contrat de lecture (liste + détail). Tu poses le contrat JSON sur lequel le front s'appuiera — c'est le livrable critique.
|
||||
|
||||
**Spec** : `spec-back.md § 3.2 / 3.3 / 3.4 / 4.0 / 4.1 / 4.2`. **Miroir = le module Supplier** : `src/Module/Commercial/Domain/Entity/Supplier*.php`, `…/Infrastructure/ApiPlatform/State/Provider/SupplierProvider.php`, `…/Serializer/SupplierReadGroupContextBuilder.php`. Carrier vit dans `src/Module/Transport/`.
|
||||
|
||||
### Étape A — Migration (`migrations/`, namespace racine `DoctrineMigrations`)
|
||||
- **PAS de migration modulaire** : même si la spec dit « modulaire », toute migration va dans `migrations/` namespace racine (tri FQCN cassant sinon). Postérieure à la dernière présente — vérifie `ls migrations/` (à ce jour `Version20260615120000`).
|
||||
- Tables `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` + FK : `qualimat_carrier`, `uploaded_document`, `client`, `client_address`, `supplier`, `supplier_address`, `site`, `user`.
|
||||
- `certification_type` **nullable** (null en cas LIOT) + CHECK enum ; CHECK sur `container_type`, `direction`, `pricing_unit`, `price_state`, branches Prix client/fournisseur.
|
||||
- Index partiel `uq_carrier_name_active` : `LOWER(name)` WHERE non archivé ET non supprimé.
|
||||
- **`COMMENT ON COLUMN` sur TOUTES les colonnes** (FR, ≤200 car.) + helper standard pour les 4 colonnes Timestampable/Blamable. Bonus `COMMENT ON TABLE`.
|
||||
|
||||
### Étape B — Entités + repos
|
||||
- `Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice` : `#[Auditable]`, `implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait`. Repos `*RepositoryInterface` (Domain) + `Doctrine*Repository` (Infrastructure).
|
||||
- `ApiResource` Carrier (attribut sur l'entité, comme Supplier) : `GetCollection` + `Get` + `Post` + `Patch` avec `security` (§ 3.3). **PAS de Delete**.
|
||||
- Groupes : `carrier:read`, `carrier:item:read`, `qualimat:read`. **Embed au détail** (pas IRI) : `client:read`/`client_address:read`/`supplier:read`/`supplier_address:read`/`site:read` + `qualimatCarrier`. ⚠ les adresses de l'onglet Prix sont des `ClientAddress`/`SupplierAddress` distinctes.
|
||||
- `CarrierProvider` paginé (`ApiPlatform\Doctrine\Orm\Paginator`), liste **sans cloisonnement site** (§ 2.3), **anti-N+1** (fetch joins, § 2.11), exclut les archivés par défaut + `?includeArchived=true`.
|
||||
- Piège booléen : `#[SerializedName('isArchived')]` sur le getter.
|
||||
|
||||
### Gardes-fous qui CASSENT `make test` (à traiter dans CE worktree)
|
||||
- `ColumnsHaveSqlCommentTest` → COMMENT partout **+ ajouter les blocs `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` dans `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php`** (sinon `test-db-setup` drope les COMMENT).
|
||||
- `makefile test-db-setup` : l'index partiel `uq_carrier_name_active` n'est PAS exprimé par `schema:update` → **ajoute-le à la ligne `dbal:run-sql` du target `test-db-setup`** du `makefile`, sinon `make test` casse.
|
||||
- `AuditableEntitiesHaveI18nLabelTest` → ajoute dans `frontend/i18n/locales/fr.json` les clés `audit.entity.transport_carrier`, `transport_carrieraddress`, `transport_carriercontact`, `transport_carrierprice` (clé = strtolower(module)+'_'+strtolower(Entity)).
|
||||
- `EntitiesAreTimestampableBlamableTest`, `EntityConstraintsHaveFrenchMessageTest` (messages FR + `Length.max` = longueur colonne), `CollectionsArePaginatedTest`.
|
||||
|
||||
**Scope STRICT** : schéma + entités + ApiResource lecture + Provider + i18n audit. **PAS** le Processor d'écriture (→ WT4), **PAS** les sous-ressources POST/PATCH adresses/contacts/prix (→ WT6/7/8), **PAS** l'export (→ WT9). Mets un `CarrierFixtures` **minimal** (1-2 lignes) juste pour faire tourner tes tests de lecture ; les fixtures complètes sont faites par WT10 — n'y investis pas.
|
||||
|
||||
**Tests à écrire** : liste exclut archivés / `?includeArchived=true` ; enveloppe Hydra (`member`/`totalItems`) ; `isArchived` présent dans le JSON ; embeds détail présents (pas IRI).
|
||||
|
||||
**LIVRABLE GATE** : une fois vert, **capture le JSON réel liste + détail** (`curl` ou test) et colle-le dans `spec-back.md § 4.0.bis`. C'est le signal pour démarrer le front. Préviens la conv maître.
|
||||
|
||||
**Fini quand** : `make db-reset` OK + `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si test vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-155-carrier-schema-entities
|
||||
tea pr create --base feat/erp-153-rbac --head feat/erp-155-carrier-schema-entities \
|
||||
--title "feat(transport) : schéma + entités Carrier + contrat lecture (ERP-155/157)" \
|
||||
--description "Migration + entités Carrier* + ApiResource lecture + Provider + i18n audit + contrat JSON. Tickets ERP-155, ERP-157."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,41 @@
|
||||
# WT4 — CarrierProcessor (ticket 1.6 / ERP-158)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp158-processor -b feat/erp-158-carrier-processor origin/develop
|
||||
> cd ../sb-erp158-processor && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de WT3** (entités Carrier) **et WT1** (upload, pour la décharge).
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
|
||||
|
||||
**Mission** : logique d'écriture du formulaire principal Carrier (POST/PATCH) — normalisation, champs conditionnels, archivage. **Miroir** : `src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/SupplierProcessor.php` + `Application/Service/SupplierFieldNormalizer.php`.
|
||||
|
||||
**Spec** : `spec-back.md § 4.3 / 4.4 / 7`.
|
||||
|
||||
**Règles métier à implémenter (un test PHPUnit par RG)** :
|
||||
- **RG-4.01** : POST avec `qualimatCarrier` → `certificationType=QUALIMAT` + FK persistée ; cas LIOT (`name='LIOT'`) ⇒ `certificationType` non requis, `liotPlates` accepté.
|
||||
- **RG-4.02** : `certificationType='AUTRE'` sans `dischargeDocument` → **422** (`#[Assert\Callback]`).
|
||||
- **RG-4.03** : `isChartered=true` sans `indexationRate` / `containerType` / `volumeM3` → **422**.
|
||||
- **RG-4.13** : normalisation via `CarrierFieldNormalizer` (miroir Supplier) — `name` UPPER, contacts Capitalize, phones digits-only, email lower, `liotPlates` (`;`-split/trim/UPPER).
|
||||
- **RG-4.12** : doublon `name` (parmi actifs) → **409** + `setError` ciblé.
|
||||
- **RG-4.14** : PATCH `isArchived` exige `transport.carriers.archive` (Admin) ; mode strict → 403 sinon.
|
||||
|
||||
**Pièges** :
|
||||
- Messages de validation **FR explicites** sur chaque contrainte (`EntityConstraintsHaveFrenchMessageTest`).
|
||||
- Le back renvoie **toutes** les violations d'un coup avec `propertyPath` aligné sur les champs front.
|
||||
|
||||
**Scope STRICT** : `CarrierProcessor` + `CarrierFieldNormalizer` + contraintes sur l'entité `Carrier` (formulaire principal). **NE TOUCHE PAS** : les sous-ressources adresses/contacts/prix (WT6/7/8), `CarrierFixtures` (WT10), l'export (WT9). Ajoute tes contraintes sur `Carrier` sans réécrire l'ApiResource posée par WT3.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-158-carrier-processor
|
||||
tea pr create --base develop --head feat/erp-158-carrier-processor \
|
||||
--title "feat(transport) : CarrierProcessor (RG-4.01→4.03/4.12→4.14) (ERP-158)" \
|
||||
--description "Normalisation + champs conditionnels + archive. Ticket ERP-158."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,37 @@
|
||||
# WT5 — Endpoint QualimatCarrier lecture seule (ticket 1.4 / ERP-156)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp156-qualimat -b feat/erp-156-qualimat-search origin/develop
|
||||
> cd ../sb-erp156-qualimat && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de WT2** (permission `transport.carriers.view`) **et ERP-39** (table `qualimat_carrier` peuplée). **Indépendant de WT3** — peut tourner en parallèle.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
|
||||
|
||||
**Mission** : exposer le référentiel QUALIMAT (table existante `qualimat_carrier`, alimentée par console) en **lecture seule** + endpoint de recherche pour la saisie assistée du nom (RG-4.01). **Ne touche pas** la commande de sync.
|
||||
|
||||
**Spec** : `spec-back.md § 4.7` + RG-4.01.
|
||||
|
||||
**À livrer** :
|
||||
1. Entité `QualimatCarrier` (lecture seule) mappée sur la table existante `qualimat_carrier`. **Aucune écriture exposée** (pas de Post/Patch/Delete). Probablement pas `#[Auditable]` ni Timestampable (référentiel externe synchronisé) — vérifie le mapping existant.
|
||||
2. `GET /api/qualimat_carriers?search=` : fuzzy sur `name` (+ `siret`), **seulement `is_active = true`**, tri `name`, **paginé** (règle n°13 — `CollectionsArePaginatedTest`).
|
||||
3. **Security** `is_granted('transport.carriers.view')`.
|
||||
4. Champs exposés : `id, siret, name, address, postalCode, city, phone, department, status, validityDate, isActive`.
|
||||
|
||||
**Tests à écrire** : recherche ne renvoie que les actifs ; pagination Hydra ; 403 sans permission ; tri `name`.
|
||||
|
||||
**Scope STRICT** : uniquement l'exposition lecture de `qualimat_carrier`. Ne crée rien autour de `Carrier` (autres worktrees). Si la table n'a pas de COMMENT (référentiel pré-existant), vérifie si elle est dans `EXCLUDED_TABLES` de `ColumnsHaveSqlCommentTest` — ne casse pas ce test.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-156-qualimat-search
|
||||
tea pr create --base develop --head feat/erp-156-qualimat-search \
|
||||
--title "feat(transport) : endpoint recherche QualimatCarrier (ERP-156)" \
|
||||
--description "Entité lecture seule + GET /api/qualimat_carriers?search=. Ticket ERP-156."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,37 @@
|
||||
# WT6 — Sous-ressource Adresses (ticket 1.7 / ERP-159)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp159-adresses -b feat/erp-159-carrier-addresses origin/develop
|
||||
> cd ../sb-erp159-adresses && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de WT3** (entités `CarrierAddress`). Parallèle à WT5/WT7/WT8/WT9.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `SupplierAddressProcessor.php` (`src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/`).
|
||||
|
||||
**Mission** : opérations d'écriture sur les adresses transporteur.
|
||||
|
||||
**Spec** : `spec-back.md § 4.5` + RG-4.05→4.07.
|
||||
|
||||
**À livrer** :
|
||||
- `POST /api/carriers/{id}/addresses`, `PATCH`/`DELETE /api/carrier_addresses/{id}` (security `manage`) — **resource/processor dédiés à `CarrierAddress`**, ne modifie pas l'ApiResource `Carrier`.
|
||||
- **RG-4.06** : `postalCode` matche `^[0-9]{4,5}$` (autocomplete ville = front). Message FR.
|
||||
- **RG-4.05** : si affrété → adresse obligatoire (Pays/CP/Ville/Adresse) — validation conditionnelle.
|
||||
- RG-4.07 (bouton Valider masqué si QUALIMAT) = front ; côté back, accepter le PATCH normalement.
|
||||
|
||||
**Tests à écrire** : CP invalide → 422 ; adresse affrété incomplète → 422 ; PATCH/DELETE OK avec `manage`, 403 sans.
|
||||
|
||||
**Scope STRICT** : uniquement `CarrierAddress` (resource + processor + tests). **NE TOUCHE PAS** `CarrierFixtures` (WT10), l'entité `Carrier`, les autres sous-ressources. Messages de validation FR (`EntityConstraintsHaveFrenchMessageTest`).
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-159-carrier-addresses
|
||||
tea pr create --base develop --head feat/erp-159-carrier-addresses \
|
||||
--title "feat(transport) : sous-ressource adresses transporteur (ERP-159)" \
|
||||
--description "POST/PATCH/DELETE carrier_address + RG-4.05→4.07. Ticket ERP-159."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,35 @@
|
||||
# WT7 — Sous-ressource Contacts (ticket 1.8 / ERP-160)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp160-contacts -b feat/erp-160-carrier-contacts origin/develop
|
||||
> cd ../sb-erp160-contacts && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de WT3**. Parallèle à WT5/WT6/WT8/WT9.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`. **Miroir** : `SupplierContactProcessor.php` (`src/Module/Commercial/…/State/Processor/`).
|
||||
|
||||
**Mission** : opérations d'écriture sur les contacts transporteur.
|
||||
|
||||
**Spec** : `spec-back.md § 4.5` + RG-4.08.
|
||||
|
||||
**À livrer** :
|
||||
- `POST /api/carriers/{id}/contacts`, `PATCH`/`DELETE /api/carrier_contacts/{id}` (security `manage`) — resource/processor dédiés à `CarrierContact`.
|
||||
- **RG-4.08** : bloc valide si **≥ 1 champ rempli** (CHECK `chk_carrier_contact_filled` côté migration WT3 + validation Processor) ; **max 2 téléphones**.
|
||||
|
||||
**Tests à écrire** : contact vide → 422 ; 1 champ → 200/201 ; 3ᵉ téléphone → 422.
|
||||
|
||||
**Scope STRICT** : uniquement `CarrierContact`. **NE TOUCHE PAS** `CarrierFixtures` (WT10), `Carrier`, les autres sous-ressources. Messages FR. Si le CHECK `chk_carrier_contact_filled` manque (WT3 ne l'a pas posé), valide côté Processor et signale-le à la conv maître.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-160-carrier-contacts
|
||||
tea pr create --base develop --head feat/erp-160-carrier-contacts \
|
||||
--title "feat(transport) : sous-ressource contacts transporteur (ERP-160)" \
|
||||
--description "POST/PATCH/DELETE carrier_contact + RG-4.08 (≥1 champ, max 2 tel). Ticket ERP-160."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,39 @@
|
||||
# WT8 — Sous-ressource Prix + RG branches (ticket 1.9 / ERP-161)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp161-prix -b feat/erp-161-carrier-prices origin/develop
|
||||
> cd ../sb-erp161-prix && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de WT3**. Parallèle à WT5/WT6/WT7/WT9.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. Charge le skill `backend-entity-conventions`.
|
||||
|
||||
**Mission** : opérations d'écriture sur les prix transporteur, avec branches Client / Fournisseur.
|
||||
|
||||
**Spec** : `spec-back.md § 4.5 / 7` + RG-4.09→4.11.
|
||||
|
||||
**À livrer** :
|
||||
- `POST /api/carriers/{id}/prices`, `PATCH`/`DELETE /api/carrier_prices/{id}` (security `manage`) — resource/processor dédiés à `CarrierPrice`.
|
||||
- **RG-4.10 (CLIENT)** : `client`, `clientDeliveryAddress`, `departureSite` requis ; `clientDeliveryAddress` **doit appartenir au `client`** → sinon 422.
|
||||
- **RG-4.11 (FOURNISSEUR)** : `supplier`, `supplierSupplyAddress`, `deliverySite` requis ; `supplierSupplyAddress` appartient au `supplier` → sinon 422.
|
||||
- Communs obligatoires : `containerType`, `pricingUnit`, `price`, `priceState`. CHECK branches respectés.
|
||||
|
||||
**Rappels FK** : « Adresse départ/livraison 86/17/82 » = `Site` (FK). Livraison client = `ClientAddress`, appro = `SupplierAddress` (relations ORM partagées — pas de M2M).
|
||||
|
||||
**Tests à écrire** : branche CLIENT/FOURNISSEUR incomplète → 422 ; adresse étrangère au client/supplier → 422 ; prix valide → 201.
|
||||
|
||||
**Scope STRICT** : uniquement `CarrierPrice`. **NE TOUCHE PAS** `CarrierFixtures` (WT10), `Carrier`, les autres sous-ressources. Messages FR.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-161-carrier-prices
|
||||
tea pr create --base develop --head feat/erp-161-carrier-prices \
|
||||
--title "feat(transport) : sous-ressource prix transporteur (ERP-161)" \
|
||||
--description "POST/PATCH/DELETE carrier_price + RG-4.09→4.11 (branches client/fournisseur). Ticket ERP-161."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
@@ -0,0 +1,36 @@
|
||||
# WT9 — Export XLSX (ticket 1.10 / ERP-162)
|
||||
|
||||
> ```bash
|
||||
> git fetch origin
|
||||
> git worktree add ../sb-erp162-export -b feat/erp-162-carrier-export origin/develop
|
||||
> cd ../sb-erp162-export && claude
|
||||
> ```
|
||||
> **Base** : `origin/develop` **après merge de WT3** (lecture Carrier). Parallèle à WT5/WT6/WT7/WT8.
|
||||
|
||||
---
|
||||
|
||||
## Prompt à coller
|
||||
|
||||
Projet Starseed (Symfony 8 / API Platform 4, DDD). Lis `CLAUDE.md`, `.claude/rules/backend.md`. **Miroir** : `src/Module/Commercial/Infrastructure/Controller/SupplierExportController.php` (PhpSpreadsheet déjà présent).
|
||||
|
||||
**Mission** : export Excel du répertoire et du tableau Prix regroupé.
|
||||
|
||||
**Spec** : `spec-back.md § 4.6`.
|
||||
|
||||
**À livrer** :
|
||||
- `GET /api/carriers/export.xlsx` : transporteurs affichés (**mêmes filtres** que la liste) ; colonnes § 4.6.
|
||||
- `GET /api/carriers/{id}/prices/export.xlsx` : tableau Prix regroupé Benne / Fond Mouvant (colonnes docx p.10).
|
||||
- **Controllers custom** avec `#[Route(priority: 1)]` (sinon conflit API Platform `{id}`) ; en-tête `Content-Disposition`.
|
||||
|
||||
**Tests à écrire** : 200 + en-tête fichier (Content-Disposition + type XLSX) ; respect des filtres.
|
||||
|
||||
**Scope STRICT** : controllers d'export + service de génération. **NE TOUCHE PAS** entités, processors, `CarrierFixtures` (WT10). Réutilise le Provider/filtres de WT3 pour la cohérence des données exportées.
|
||||
|
||||
**Fini quand** : `make test` vert + `make php-cs-fixer-allow-risky`. Commit (`--no-verify` si vert), puis **ouvre la PR** :
|
||||
```bash
|
||||
git push -u origin feat/erp-162-carrier-export
|
||||
tea pr create --base develop --head feat/erp-162-carrier-export \
|
||||
--title "feat(transport) : export XLSX répertoire + prix (ERP-162)" \
|
||||
--description "GET /api/carriers/export.xlsx + /carriers/{id}/prices/export.xlsx. Ticket ERP-162."
|
||||
```
|
||||
Puis labellise via l'API Gitea. Cible **develop**. Aucune mention IA.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,354 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M4
|
||||
nom: "Répertoire transporteurs"
|
||||
ecran: repertoire-transporteurs
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0.1
|
||||
date_redaction: 2026-06-15
|
||||
# Historique :
|
||||
# V0.1 (2026-06-15) — Restitution Markdown du docx « M4-repertoire-transporteurs-V0 »
|
||||
# (validé 27/05/2026) + maquette Figma (node 1132-45376). Précisions techniques (back)
|
||||
# dans spec-back.md. Réutilise le pattern et les composants M1/M2/M3.
|
||||
|
||||
# === LIENS ===
|
||||
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-45376&p=f&m=dev"
|
||||
regles_metier: [RG-4.01, RG-4.02, RG-4.03, RG-4.04, RG-4.05, RG-4.06, RG-4.07, RG-4.08, RG-4.09, RG-4.10, RG-4.11]
|
||||
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||
lien_spec_back: ./spec-back.md
|
||||
|
||||
# === VALIDATION CLIENT ===
|
||||
client_validation_1:
|
||||
statut: validee
|
||||
date: 2026-05-27
|
||||
version: V0
|
||||
valide_par: "Matthieu (CP MALIO)"
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_project_id: 6
|
||||
lesstime_taskgroup_id: 31 # M4 — Répertoire transporteurs (tickets ERP-153 → ERP-171)
|
||||
statut_global: pret_a_dev
|
||||
---
|
||||
|
||||
# Module 4 — Répertoire transporteurs (V0.1 front)
|
||||
|
||||
> **Origine** : spec fonctionnelle `M4-repertoire-transporteurs-V0` (validée le 27/05/2026) + maquette Figma. Restitution Markdown pour intégration au workflow MALIO. Toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M4 réutilise le pattern et les composants posés aux [M1 clients](../M1-clients/spec-front.md), [M2 fournisseurs](../M2-suppliers/spec-front.md) et [M3 prestataires](../M3-prestataires/spec-front.md).
|
||||
|
||||
> **Socle déjà en place** : le module back `Transport` existe (ERP-150) et porte deux référentiels **synchronisés par commandes console** : transporteurs **QUALIMAT** (`qualimat_carrier`, ERP-39) et codes **IDTF** (`idtf_product`, ERP-149). Le M4 ajoute le **répertoire éditable** (`Carrier`) **par-dessus** ces référentiels — la saisie assistée du nom interroge le référentiel QUALIMAT (RG-4.01). L'IDTF n'est **pas** utilisé par ces écrans.
|
||||
|
||||
> **Décisions Matthieu (15/06/2026)** : (1) lien QUALIMAT = FK + **copie éditable** des champs (nom / certification / adresse) ; (2) **pas de cloisonnement par site** (référentiel global) ; (3) le champ « Décharge » s'appuie sur une **infra d'upload réutilisable** (`Shared`), car d'autres uploads suivront. Détails : [`spec-back.md § 2.5 / § 2.3 / § 2.7`](./spec-back.md).
|
||||
|
||||
## But
|
||||
|
||||
Lister tous les transporteurs de l'organisation et accéder rapidement à leurs fiches : consultation, création, modification, archivage. Le nom est **relié à QUALIMAT** (saisie assistée) ; les transporteurs hors QUALIMAT (GMP+, OVOCOM, compte-propre, LIOT, autre) sont saisis manuellement.
|
||||
|
||||
## Accès
|
||||
|
||||
- **Depuis** : menu principal → section **Transport** (route `/carriers`). *(Section « Transport » dédiée ou rattachement à une section « Logistique » — à confirmer, cf. [`spec-back.md § 5.3`](./spec-back.md).)*
|
||||
- **Rôles autorisés** (tableau « Rôles & permissions » du docx) :
|
||||
|
||||
| Rôle | Consultation | Ajout / Modification | Archive |
|
||||
|---|---|---|---|
|
||||
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
|
||||
| **Bureau** | ✅ Tout | ✅ Tout | ❌ |
|
||||
| **Compta** | ❌ | ❌ | ❌ |
|
||||
| **Commerciale** | ✅ Tout | ❌ | ❌ |
|
||||
| **Usine** | ❌ | ❌ | ❌ |
|
||||
|
||||
> **Notes** :
|
||||
> - RBAC transposée sur `transport.carriers.*` (cf. [`spec-back.md § 5`](./spec-back.md)). **Commerciale** = consultation seule (pas de « + Ajouter » ni « Modifier »). **Compta** et **Usine** n'ont **aucun** accès au module (item sidebar masqué).
|
||||
> - **Pas de cloisonnement par site** (≠ M3) : tout rôle autorisé voit tous les transporteurs.
|
||||
|
||||
## Navigation
|
||||
|
||||
Page d'entrée du module **Transport** (route `/carriers`). Titre : « **Répertoire transporteurs** ».
|
||||
|
||||
- Affichage principal : un **datatable** listant tous les transporteurs **actifs** (les archivés sont masqués par défaut — filtre dédié).
|
||||
- **Clic sur une ligne** → écran **Consultation transporteur** (page dédiée).
|
||||
- **Bouton « + Ajouter »** (haut droite, si `manage`) → écran **Ajouter un transporteur**.
|
||||
- **Bouton « Filtrer »** (haut droite) → panneau de filtres.
|
||||
- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des transporteurs **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md).
|
||||
|
||||
### Panneau de filtres (bouton « Filtrer »)
|
||||
|
||||
Réutilise le pattern M1/M2/M3. Filtres branchés sur les query params de `GET /api/carriers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) :
|
||||
|
||||
| Filtre | Composant | Query param back |
|
||||
|---|---|---|
|
||||
| **Recherche** (nom) | `<MalioInputText>` | `?search=` |
|
||||
| **Certification** | `<MalioSelectCheckbox>` (QUALIMAT / GMP+ / OVOCOM / Compte-propre / Autre) | `?certificationType=` |
|
||||
| **Inclure les archivés** | `<MalioCheckbox>` | `?includeArchived=true` |
|
||||
|
||||
- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**).
|
||||
- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6).
|
||||
|
||||
## Datatable du Répertoire
|
||||
|
||||
Composant : `<MalioDataTable>` branché sur `usePaginatedList<Carrier>({ url: '/carriers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes :
|
||||
|
||||
| Colonne | Source | Tri |
|
||||
|---|---|---|
|
||||
| **Nom** | `carrier.name` | ASC par défaut |
|
||||
| **Certification** | `carrier.certificationType` (libellé i18n) | Non |
|
||||
| **Date de validité** | `carrier.qualimatCarrier.validityDate` (format `JJ-MM-AAAA`) — **fond rouge si < aujourd'hui** (RG-4.04) | Non |
|
||||
| **Dernière activité** | `carrier.updatedAt` (format `JJ-MM-AAAA`) | Oui |
|
||||
|
||||
> **Clic sur une ligne** → écran Consultation. **Pagination** : standard Starseed 10 / 25 / 50 (défaut 10). Tri serveur `name ASC` par défaut.
|
||||
|
||||
## Écran « Ajouter un transporteur »
|
||||
|
||||
Création par **onglets successifs avec validation incrémentale** : pour passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant** ; les champs validés passent en lecture seule. **L'onglet Adresses n'est accessible qu'une fois le formulaire principal validé.** Cf. [`spec-back.md § 2.9`](./spec-back.md) (PATCH partiels par groupe de sérialisation).
|
||||
|
||||
**Accès** : bouton « + Ajouter » du Répertoire. **Rôles** : Admin, Bureau.
|
||||
|
||||
**Barre d'onglets** : `Qualimat` · `Adresses` · `Contacts` · `Prix`.
|
||||
|
||||
### Formulaire principal (pré-onglets)
|
||||
|
||||
1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/carriers`, puis bascule sur l'onglet Qualimat/Adresses ; les champs passent en readonly.
|
||||
|
||||
| Champ | Type composant | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom** (saisie assistée reliée à QUALIMAT) | `<MalioInputText>` (autocomplete) | Oui | RG-4.01 ; RG-4.13 (UPPERCASE serveur) ; RG-4.12 (unicité) |
|
||||
| **Liste certification transport** | `<MalioSelect>` (GMP+ / OVOCOM / Compte-propre / Autre) | Oui | RG-4.02 ; auto = `QUALIMAT` (lecture seule) si transporteur QUALIMAT sélectionné |
|
||||
| **Affréter** | `<MalioCheckbox>` | Non | RG-4.03 |
|
||||
| **Indexation %** | `<MalioInputNumber>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
|
||||
| **Benne / Fond mouvant** | `<MalioRadioButton>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
|
||||
| **Volume m³** | `<MalioInputNumber>` | Conditionnel | RG-4.03 — visible + obligatoire si « Affréter » coché |
|
||||
| **Décharge** | `<MalioInputUpload>` *(cf. note)* | Conditionnel (**obligatoire si AUTRE**) | RG-4.02 — visible **et obligatoire** si certification = `AUTRE`. Upload via infra Shared ([`spec-back.md § 2.7`](./spec-back.md)) |
|
||||
| **Liste immatriculation LIOT** | `<MalioInputText>` (ou TextArea) | Cas LIOT | RG-4.01 — visible **uniquement** si nom = `LIOT` ; les autres champs disparaissent. Immatriculations séparées par `;` |
|
||||
|
||||
> **Comportement RG-4.01 (saisie assistée)** : à la saisie du nom, recherche dans le référentiel QUALIMAT via `GET /api/qualimat_carriers?search=`. Sélection d'un résultat → **modal de confirmation** « Êtes-vous sûr de vouloir intégrer ce transporteur ? ». Si confirmé : le **Nom** et la **certification** (= `QUALIMAT`, lecture seule) se remplissent automatiquement, **ainsi que l'onglet Adresse** (copie pays/CP/ville/voie depuis le référentiel). La FK QUALIMAT est conservée (traçabilité + date de validité RG-4.04).
|
||||
> - **Cas transporteur non trouvé** (pas QUALIMAT) : l'utilisateur choisit une autre certification (RG-4.02) → affichage des champs associés.
|
||||
> - **Cas LIOT** : si le nom saisi est exactement `LIOT`, seul le champ « Liste immatriculation LIOT » s'affiche, les autres champs sont masqués.
|
||||
|
||||
> **Note `<MalioInputUpload>`** : si le composant ne couvre pas le drag & drop / type fichier requis, exception autorisée documentée (`// TODO migrer quand Malio couvre`) — cf. exceptions @.claude/rules/frontend.md.
|
||||
|
||||
**Action** : « Valider » (`<MalioButton>`) → POST `/api/carriers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Qualimat » / « Adresses ».
|
||||
|
||||
### Onglet « Qualimat »
|
||||
|
||||
Sélectionner un transporteur de la liste QUALIMAT afin de mettre à jour les informations du transporteur (saisie assistée — voir RG-4.01).
|
||||
|
||||
**Colonnes du tableau de sélection** :
|
||||
|
||||
| Colonne | Règle |
|
||||
|---|---|
|
||||
| **Sélection** (bouton / clic ligne) | RG-4.03 *(docx)* — clic → modal « Êtes-vous sûr de vouloir intégrer ce transporteur ? » → remplit Nom + certification + onglet adresse |
|
||||
| **Nom** | — |
|
||||
| **Adresse** | — |
|
||||
| **Date de validité** | RG-4.04 — **fond rouge si < date du jour** |
|
||||
|
||||
> Cet onglet alimente le formulaire principal et l'onglet Adresse par copie (RG-4.01 / RG-4.05). Source : `GET /api/qualimat_carriers?search=` (lecture seule, lignes actives uniquement).
|
||||
|
||||
### Onglet « Adresses »
|
||||
|
||||
Saisir l'adresse du transporteur (un bloc par adresse).
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Pays** | `<MalioSelect>` (préremplie « France ») | Conditionnel | RG-4.05 |
|
||||
| **Code postal** | `<MalioInputText>` (saisie assistée) | Conditionnel | RG-4.06, RG-4.05 — déclenche autocomplete ville (BAN) |
|
||||
| **Ville** | `<MalioSelect>` (saisie assistée) | Conditionnel | RG-4.06, RG-4.05 — alimentée par api-adresse.data.gouv.fr |
|
||||
| **Adresse** | `<MalioInputText>` (saisie assistée) | Conditionnel | RG-4.05 |
|
||||
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
|
||||
|
||||
> **RG-4.05** : les champs sont **déjà remplis** si le transporteur est QUALIMAT (copie). Si « Affréter » est coché, l'adresse devient **obligatoire** (Pays, Code postal, Ville, Adresse).
|
||||
> **RG-4.06** : la ville est préremplie automatiquement à partir du code postal via l'API BAN (`useAddressAutocomplete()`, réutilisé M1/M2/M3). Si plusieurs villes → choix dans le select. L'adresse est une saisie assistée basée sur le CP et la ville.
|
||||
> **RG-4.07** : le bouton « Valider » **n'apparaît pas** pour un transporteur QUALIMAT (adresse remplie automatiquement).
|
||||
|
||||
**Actions** : « Valider » → PATCH `/api/carriers/{id}/addresses` (sauf QUALIMAT, RG-4.07).
|
||||
|
||||
### Onglet « Contacts »
|
||||
|
||||
Saisir un ou plusieurs contacts associés au transporteur.
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom** | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (Capitalize) |
|
||||
| **Prénom** | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (Capitalize) |
|
||||
| **Fonction** | `<MalioInputText>` | Non | RG-4.08 |
|
||||
| **Téléphone** (x1, +1 possible, **max 2**) | `<MalioInputText>` | Non | RG-4.08 + RG-4.13 (format) |
|
||||
| **Email** | `<MalioInputText>` type email | Non | RG-4.08 + RG-4.13 (lowercase) |
|
||||
|
||||
**RG-4.08** : un bloc Contact est valide dès qu'au moins 1 champ est rempli. Impossible d'ajouter un nouveau bloc tant que le précédent n'est pas valide.
|
||||
|
||||
**Actions** :
|
||||
- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a aucun champ rempli** (RG-4.08).
|
||||
- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc.
|
||||
- « Valider » → PATCH `/api/carriers/{id}/contacts`.
|
||||
|
||||
### Onglet « Prix »
|
||||
|
||||
Saisir un suivi de prix du transporteur (un bloc par prix). Tous les champs sont masqués par défaut sauf le radio « Client / Fournisseur » (RG-4.09).
|
||||
|
||||
**Bloc Prix** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Client / Fournisseur** | `<MalioRadioButton>` | Oui | RG-4.09 |
|
||||
| **Client** | `<MalioSelect>` (liste des clients) | Conditionnel | RG-4.10 — si Client |
|
||||
| **Adresse de livraison** | `<MalioSelect>` (adresses du client sélectionné) | Conditionnel | RG-4.10 — si Client |
|
||||
| **Adresse de départ** | `<MalioSelect>` (86 / 17 / 82) | Conditionnel | RG-4.10 — si Client ; = un des 3 sites |
|
||||
| **Fournisseur** | `<MalioSelect>` (liste des fournisseurs) | Conditionnel | RG-4.11 — si Fournisseur |
|
||||
| **Adresse d'approvisionnement** | `<MalioSelect>` (adresses du fournisseur) | Conditionnel | RG-4.11 — si Fournisseur |
|
||||
| **Adresse de livraison** | `<MalioSelect>` (86 / 17 / 82) | Conditionnel | RG-4.11 — si Fournisseur ; = un des 3 sites |
|
||||
| **Benne / Fond mouvant (FM)** | `<MalioRadioButton>` | Oui | — |
|
||||
| **Forfait / Tonne** | `<MalioRadioButton>` | Oui | — |
|
||||
| **Prix** | `<MalioInputAmount>` (monnaie) | Oui | — |
|
||||
| **État du prix** | `<MalioSelect>` (En cours / Validé / Non validé) | Oui | — |
|
||||
|
||||
> **RG-4.10** : si **Client** sélectionné → champs liés au client affichés et obligatoires ; champs fournisseur masqués et non obligatoires.
|
||||
> **RG-4.11** : si **Fournisseur** sélectionné → champs liés au fournisseur affichés et obligatoires ; champs client masqués et non obligatoires.
|
||||
> **Adresse de départ / livraison « 86 / 17 / 82 »** = les 3 `Site` fixes (cf. switcher de site Châtellerault / Saint-Jean / Pommevic en haut de l'app). La sélection stocke un **ID de Site** ([`spec-back.md § 3.2`](./spec-back.md)).
|
||||
|
||||
**Actions** :
|
||||
- « + Nouveau prix » : ajoute un bloc. Bloqué tant que le précédent n'est pas valide.
|
||||
- « Supprimer » (icône) : modal de confirmation puis suppression.
|
||||
- « Valider » → PATCH `/api/carriers/{id}/prices`.
|
||||
|
||||
## Écran « Consultation d'un transporteur »
|
||||
|
||||
Consulter en **lecture seule** la fiche complète. Affiche en haut du bloc les infos principales du transporteur (comme l'écran d'ajout) ainsi que les onglets Adresses, Contacts, Prix. **Tous les champs sont en lecture seule.**
|
||||
|
||||
**Accès** : clic sur une ligne du Répertoire. La page s'ouvre par défaut sur l'onglet **Adresses**. Icône « flèche » à gauche pour revenir au répertoire. Deux boutons à droite :
|
||||
- **« Modifier »** (visible si `transport.carriers.manage` → Admin, Bureau).
|
||||
- **« Archiver »** (visible **uniquement Admin** via `transport.carriers.archive`) → modal de confirmation, puis PATCH `/api/carriers/{id}` `{ "isArchived": true }`.
|
||||
|
||||
> Un transporteur archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé.
|
||||
|
||||
### Onglet Adresses (consultation)
|
||||
|
||||
Un bloc par adresse du transporteur. Chaque bloc, 5 champs en lecture seule : Pays / Code postal / Ville / Adresse / Adresse complémentaire.
|
||||
|
||||
### Onglet Contacts (consultation)
|
||||
|
||||
Un bloc par contact. 5 champs en lecture seule : Nom / Prénom / Fonction / Téléphone (x1 ou x2) / Email.
|
||||
|
||||
### Onglet Prix (consultation)
|
||||
|
||||
Un tableau regroupant les prix par type (**Fond Mouvant / Benne**) :
|
||||
|
||||
| Colonne | Description |
|
||||
|---|---|
|
||||
| **Colonne de regroupement** | « Fond Mouvant » / « Benne » |
|
||||
| **Transporteurs** | Nom du transporteur |
|
||||
| **Adresse APRO ou Adresse Sites** | Si prix « Client » → Adresse APRO sinon Adresse Sites |
|
||||
| **Adresse livraisons** | — |
|
||||
| **Forfait €** | Prix |
|
||||
| **Tonne €** | Prix |
|
||||
| **Indexation** | Pourcentage d'indexation (vide si non rempli) |
|
||||
| **État du prix** | Validé / Non Validé / En cours |
|
||||
|
||||
**Action** : « Exporter » → exporte le tableau au **format Excel** (`GET /api/carriers/{id}/prices/export.xlsx`).
|
||||
|
||||
## Écran « Modification d'un transporteur »
|
||||
|
||||
Modifier les informations d'un transporteur existant. **Identique à l'écran « Ajouter un transporteur »** — mêmes formulaires, mêmes règles métier (RG-4.01 à RG-4.11) — sauf :
|
||||
- Les champs sont **pré-remplis** avec les valeurs actuelles.
|
||||
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
|
||||
- **Accès** : depuis l'écran Consultation, bouton « Modifier » (Admin, Bureau).
|
||||
|
||||
## Composants UI à utiliser (`@malio/layer-ui`)
|
||||
|
||||
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
|
||||
- **Input texte** : `<MalioInputText>`
|
||||
- **Input nombre / montant** : `<MalioInputNumber>` (indexation, volume), `<MalioInputAmount>` (prix)
|
||||
- **Select simple** : `<MalioSelect>` (certification, pays, ville, client, fournisseur, adresses, sites, état du prix)
|
||||
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (filtres certification)
|
||||
- **Radio** : `<MalioRadioButton>` (Benne/Fond mouvant, Forfait/Tonne, Client/Fournisseur)
|
||||
- **Checkbox** : `<MalioCheckbox>` (Affréter, inclure archivés)
|
||||
- **Upload** : `<MalioInputUpload>` (Décharge — exception documentée si type non couvert)
|
||||
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
|
||||
- **Toasts** : standards via `useApi()`
|
||||
- **Validation par champ** : `useFormErrors` (mapping 422 inline — règle frontend obligatoire)
|
||||
|
||||
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
|
||||
- Modal de confirmation : wrapper partagé dans `frontend/shared/` (réutiliser celui du M1/M2/M3).
|
||||
- `<MalioInputUpload>` si le type fichier / drag & drop n'est pas couvert.
|
||||
|
||||
## Composables & appels API
|
||||
|
||||
- `usePaginatedList<Carrier>({ url: '/carriers' })` — liste paginée (obligatoire). Consomme `name`, `certificationType`, `qualimatCarrier.validityDate` (RG-4.04), `updatedAt` (cf. [`spec-back.md § 2.11 / § 4.0`](./spec-back.md)).
|
||||
- `useCarrier(id)` — charge le détail via `GET /api/carriers/{id}`, qui **embarque** `addresses`, `contacts`, `prices` (avec `client`/`supplier`/sites imbriqués) + `qualimatCarrier`. Écrans Consultation et Modification peuplés depuis cette seule réponse. **DoD avant intégration** : vérifier le JSON réel (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)).
|
||||
- `useCarrierForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useSupplierForm()`/`useProviderForm()` + gestion des **champs conditionnels** (Affréter, AUTRE→Décharge, cas LIOT).
|
||||
- `useQualimatSearch()` — saisie assistée du nom : `GET /api/qualimat_carriers?search=`, modal de confirmation, copie des champs + FK (RG-4.01).
|
||||
- `useAddressAutocomplete()` — **réutilisé** du M1/M2/M3 (BAN), pas de réécriture (RG-4.06).
|
||||
- `useUpload()` (NOUVEAU, infra Shared) — POST multipart `/api/uploaded_documents` → renvoie l'IRI à poser sur `carrier.dischargeDocument` (RG-4.02).
|
||||
- `usePermissions()` — masque l'item sidebar et les boutons selon les permissions.
|
||||
- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4).
|
||||
- Filter `formatPhoneFR()` — **réutilisé** pour l'affichage `XX XX XX XX XX`.
|
||||
|
||||
## Règles de formatage et normalisation
|
||||
|
||||
Le serveur normalise systématiquement (RG-4.13 — cf. [`spec-back.md`](./spec-back.md)) :
|
||||
|
||||
| Champ | Normalisation serveur | Affichage front |
|
||||
|---|---|---|
|
||||
| Nom transporteur (`name`) | UPPERCASE intégral | UPPERCASE |
|
||||
| Nom + Prénom contact | Capitalize | identique |
|
||||
| Téléphones (`CarrierContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` (filter Vue) |
|
||||
| Email | lowercase intégral | identique |
|
||||
| Immatriculations LIOT | `;`-split, trim, UPPER | listées |
|
||||
|
||||
> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur normalisée que l'UI affiche.
|
||||
|
||||
## API adresse postale
|
||||
|
||||
Code postal + Ville + Adresse branchés sur **api-adresse.data.gouv.fr** (BAN) via le composable `useAddressAutocomplete()` **déjà créé au M1/M2/M3** (réutilisé tel quel) :
|
||||
- À la saisie du CP (5 chiffres) : `GET https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville (RG-4.06 : si plusieurs villes, choix dans le select).
|
||||
- À la saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions.
|
||||
- Cas dégradé (timeout / offline) : Ville en `<MalioInputText>` libre + toast d'avertissement.
|
||||
|
||||
## Différences notables avec les modules précédents
|
||||
|
||||
| Zone | M2/M3 | M4 transporteurs |
|
||||
|---|---|---|
|
||||
| Source du nom | saisie libre | **saisie assistée reliée à QUALIMAT** (référentiel synchronisé) |
|
||||
| Onglet Comptabilité / RIB | présent (M2/M3) | **Absent** |
|
||||
| Cloisonnement par site | M3 : oui | **Non** (référentiel global) |
|
||||
| Champs conditionnels formulaire principal | peu | **Nombreux** (Affréter, AUTRE→Décharge, cas LIOT) |
|
||||
| Onglet Prix | absent | **Présent** (Client/Fournisseur, sites départ/livraison) |
|
||||
| Upload de fichier | aucun | **Décharge** (infra upload Shared, réutilisable) |
|
||||
| Module | Commercial / Technique | **Transport** (existant, ERP-150) |
|
||||
|
||||
## Points résolus côté back
|
||||
|
||||
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|
||||
|---|---|---|
|
||||
| 1 | Lien QUALIMAT | FK `qualimatCarrier` + **copie éditable** des champs (§ 2.5) |
|
||||
| 2 | Cas LIOT | Champ `liotPlates` (`;`-séparé), autres champs masqués (RG-4.01) |
|
||||
| 3 | Certification QUALIMAT | Valeur `QUALIMAT` lecture seule si lié (§ 2.5) |
|
||||
| 4 | Décharge (upload) | Infra upload générique `Shared` réutilisable (§ 2.7) |
|
||||
| 5 | Onglet Prix — branches | M2M absentes : FK Client/Supplier + adresses + sites (RG-4.10/4.11, § 3.2) |
|
||||
| 6 | Adresse de départ/livraison 86/17/82 | = les 3 `Site` fixes (FK Site) |
|
||||
| 7 | Workflow par onglet | Sauvegarde incrémentale (POST principal + PATCH partiels) — pas d'état « draft » |
|
||||
| 8 | Archive vs delete | Flag `is_archived` séparé ; archivage Admin seul ; soft delete = HP |
|
||||
| 9 | Unicité métier | Nom seul (§ 2.6) |
|
||||
| 10 | Référentiel QUALIMAT | Endpoint lecture seule `GET /api/qualimat_carriers?search=` (§ 4.7) |
|
||||
| 11 | Format export | XLSX (répertoire + onglet Prix regroupé Benne/FM) |
|
||||
| 12 | RBAC | `transport.carriers.view/manage/archive` ; Compta + Usine sans accès (§ 5.2) |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tickets Lesstime
|
||||
|
||||
**TaskGroup Lesstime** : à créer — `M4 — Répertoire transporteurs` (projet `ERP / Starseed`, projectId=6). Découpe détaillée (back en tête) → [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper).
|
||||
|
||||
| Ordre | Sujet | Tag |
|
||||
|---|---|---|
|
||||
| 0 | Permissions `transport.carriers.*` + sidebar + 3 sources RBAC | Backend |
|
||||
| 1 | Infra upload générique `Shared` (uploaded_document + FileUploader + endpoint) | Backend |
|
||||
| 2 | Migration BDD M4 (carrier + sous-collections + index + COMMENT) | Backend |
|
||||
| 3 | Entité `QualimatCarrier` (lecture seule) + endpoint recherche | Backend |
|
||||
| 4 | Entités + Repositories Carrier* | Backend |
|
||||
| 5 | CarrierProvider + CarrierProcessor (champs conditionnels, archive, LIOT) | Backend |
|
||||
| 6 | Sous-ressources Adresses / Contacts / Prix (RG-4.10/4.11) | Backend |
|
||||
| 7 | Export XLSX (répertoire + onglet Prix) | Backend |
|
||||
| 8 | Tests PHPUnit RG-4.01→4.14 + capture contrat JSON | Backend |
|
||||
| 9 | Page Répertoire (`/carriers`) + usePaginatedList | Frontend |
|
||||
| 10 | Page Ajouter + formulaire principal + saisie assistée QUALIMAT | Frontend |
|
||||
| 11 | Onglets Adresses (BAN) / Contacts / Prix | Frontend |
|
||||
| 12 | Pages Consultation + Modification | Frontend |
|
||||
| 13 | i18n + libellés audit + upload front (useUpload) | Frontend |
|
||||
@@ -0,0 +1,307 @@
|
||||
# M4 — Répertoire transporteurs · Découpe en tickets Lesstime
|
||||
|
||||
> **Statut** : ✅ **poussé dans Lesstime** — TaskGroup **#31 « M4 — Répertoire transporteurs »** (projet STARSEED), 19 tickets **ERP-153 → ERP-171** au statut **Prêt à dev**.
|
||||
> **Assignation** : tickets **Backend (1.1→1.11, ERP-153→163) → Matthieu** · tickets **Frontend (1.12→1.19, ERP-164→171) → Tristan**.
|
||||
>
|
||||
> | Pos | Ticket | Réf |
|
||||
> |---|---|---|
|
||||
> | 1.1 | Permissions transport.carriers.* + sidebar | ERP-153 |
|
||||
> | 1.2 | Infra upload générique Shared | ERP-154 |
|
||||
> | 1.3 | Migration BDD M4 | ERP-155 |
|
||||
> | 1.4 | QualimatCarrier + endpoint recherche | ERP-156 |
|
||||
> | 1.5 | Entités Carrier* + ApiResource + Provider | ERP-157 |
|
||||
> | 1.6 | CarrierProcessor (RG-4.01/02/03 + LIOT) | ERP-158 |
|
||||
> | 1.7 | Sous-ressource Adresses | ERP-159 |
|
||||
> | 1.8 | Sous-ressource Contacts | ERP-160 |
|
||||
> | 1.9 | Sous-ressource Prix + branches | ERP-161 |
|
||||
> | 1.10 | Export XLSX | ERP-162 |
|
||||
> | 1.11 | Tests PHPUnit + contrat JSON | ERP-163 |
|
||||
> | 1.12 | Page Répertoire /carriers | ERP-164 |
|
||||
> | 1.13 | Page Ajouter (layout + formulaire) | ERP-165 |
|
||||
> | 1.14 | Saisie assistée QUALIMAT + conditionnels | ERP-166 |
|
||||
> | 1.15 | Onglet Adresses (BAN) | ERP-167 |
|
||||
> | 1.16 | Onglet Contacts | ERP-168 |
|
||||
> | 1.17 | Onglet Prix | ERP-169 |
|
||||
> | 1.18 | Consultation + Modification | ERP-170 |
|
||||
> | 1.19 | Upload front + i18n + audit | ERP-171 |
|
||||
> **Specs sources** : [`spec-back.md`](./spec-back.md) · [`spec-front.md`](./spec-front.md) — validées (docx V0 du 27/05/2026).
|
||||
> **Maquette Figma** : node `1132-45376` ([lien](https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-45376&p=f&m=dev)).
|
||||
|
||||
## ⚠️ Dépendance amont (socle Tristan — en cours de merge)
|
||||
|
||||
Le M4 s'appuie sur le module `Transport` et le référentiel QUALIMAT, livrés par les PR de Tristan **en cours de merge** dans `develop` :
|
||||
|
||||
- **ERP-150** (PR #97) — module `Transport` (`TransportModule`, layer front, `config/modules.php`). **Requis** par tout le M4.
|
||||
- **ERP-39** (PR #99) — sync QUALIMAT (`qualimat_carrier` + commande `app:qualimat:sync`). **Requis** par la saisie assistée (ticket 1.4).
|
||||
- **ERP-149** (PR #101) — sync IDTF (`idtf_product`). **NON requis** par le M4 (référentiel autonome, hors écrans transporteurs).
|
||||
|
||||
> Les 3 PR sont **empilées** (`develop → ERP-150 → ERP-39 → ERP-149`). Démarrer le M4 une fois **ERP-150 + ERP-39 dans `develop`** (DoR des tickets 1.1 et 1.4). Brancher le M4 sur `develop` post-merge.
|
||||
|
||||
## Vue d'ensemble (ordre d'exécution)
|
||||
|
||||
| # | Ticket | Tag | Effort | RG / dépend |
|
||||
|---|---|---|---|---|
|
||||
| 1.1 | Déclarer permissions `transport.carriers.*` + sidebar | Backend | S | DoR : ERP-150 mergé |
|
||||
| 1.2 | Créer l'infra d'upload générique `Shared` | Backend | M | § 2.7 |
|
||||
| 1.3 | Migrer le schéma BDD M4 (carrier + sous-tables) | Backend | M | § 3.2 |
|
||||
| 1.4 | Exposer `QualimatCarrier` (lecture seule) + endpoint recherche | Backend | S | RG-4.01 · DoR : ERP-39 mergé |
|
||||
| 1.5 | Créer entités `Carrier*` + repos + `ApiResource` + `CarrierProvider` | Backend | M | § 3.3 / 4.0 |
|
||||
| 1.6 | Implémenter `CarrierProcessor` (RG-4.01/4.02/4.03 + LIOT + normalisation + archive) | Backend | M | RG-4.01→4.03, 4.13, 4.14 |
|
||||
| 1.7 | Sous-ressource Adresses (`carrier_address`) | Backend | S | RG-4.05→4.07 |
|
||||
| 1.8 | Sous-ressource Contacts (`carrier_contact`) | Backend | S | RG-4.08 |
|
||||
| 1.9 | Sous-ressource Prix (`carrier_price`) + RG branches | Backend | M | RG-4.09→4.11 |
|
||||
| 1.10 | Export XLSX (répertoire + onglet Prix regroupé) | Backend | M | § 4.6 |
|
||||
| 1.11 | Tests PHPUnit RG-4.01→4.14 + capture contrat JSON (DoD) | Backend | M | § 4.0.bis / 8.1 |
|
||||
| 1.12 | Page Répertoire `/carriers` (datatable, filtres, export) | Frontend | M | RG-4.04 |
|
||||
| 1.13 | Page Ajouter `/carriers/new` (layout, onglets, formulaire principal POST) | Frontend | M | RG-4.12 |
|
||||
| 1.14 | Saisie assistée QUALIMAT + champs conditionnels (Affréter / AUTRE→Décharge / LIOT) | Frontend | M | RG-4.01→4.03 |
|
||||
| 1.15 | Onglet Adresses (autocomplete BAN) | Frontend | M | RG-4.05→4.07 |
|
||||
| 1.16 | Onglet Contacts | Frontend | S | RG-4.08 |
|
||||
| 1.17 | Onglet Prix (Client/Fournisseur, sites) | Frontend | M | RG-4.09→4.11 |
|
||||
| 1.18 | Pages Consultation + Modification | Frontend | M | — |
|
||||
| 1.19 | Upload front (`useUpload`) + i18n + libellés audit | Frontend | S | § 2.8 |
|
||||
|
||||
**Total** : 19 tickets · ~11 back / 8 front · mini-MR de 1 à 4h.
|
||||
|
||||
---
|
||||
|
||||
## Tickets — détail
|
||||
|
||||
### 1.1 — Déclarer permissions `transport.carriers.*` + sidebar
|
||||
**Position** : 1.1 • Suit : — • Précède : Migrer le schéma BDD M4
|
||||
**Tag** : Backend • **Effort** : S
|
||||
**Contexte** : `TransportModule::permissions()` renvoie aujourd'hui `[]`. Ce ticket pose le socle RBAC du module et son entrée de menu, prérequis de toute opération sécurisée.
|
||||
**Spec liée** : [`spec-back.md § 5`](./spec-back.md) · [`spec-front.md § Accès`](./spec-front.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] `TransportModule::permissions()` déclare `transport.carriers.view`, `transport.carriers.manage`, `transport.carriers.archive` ; `app:sync-permissions` les enregistre.
|
||||
- [ ] **Matrice § 5.2** : Admin (view+manage+archive), Bureau (view+manage), Commerciale (view), Compta + Usine (aucune).
|
||||
- [ ] **3 sources RBAC alignées dans le même commit** (règle ABSOLUE n°8) : `config/sidebar.php` (section Transport + item `/carriers` + permission), `personas.ts`, `SeedE2ECommand.php`.
|
||||
- [ ] Item sidebar masqué pour Compta/Usine ; visible Admin/Bureau/Commerciale.
|
||||
**Tests à prévoir** : permissions sync OK ; personas e2e cohérents (pas de drift).
|
||||
**Tips** : DoR — ERP-150 mergé (module Transport présent). Section sidebar « Transport » (ou « Logistique » — à trancher, cosmétique).
|
||||
|
||||
### 1.2 — Créer l'infra d'upload générique `Shared`
|
||||
**Position** : 1.2 • Suit : permissions • Précède : Migration M4
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Contexte** : la « Décharge » (RG-4.02) est le 1er d'une série d'uploads à venir. On pose une infra réutilisable, pas un upload ad hoc.
|
||||
**Spec liée** : [`spec-back.md § 2.7`](./spec-back.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Table `uploaded_document` (`original_filename`, `stored_path`, `mime_type`, `size_bytes`, `checksum`, `created_at`, `created_by`) + COMMENT ON COLUMN.
|
||||
- [ ] Service `Shared\Infrastructure\Upload\FileUploader` : validation MIME **server-side via `$file->getMimeType()`** (jamais `getClientMimeType()`), bornage taille, checksum sha256, écriture disque (`var/uploads/{yyyy}/{mm}/`).
|
||||
- [ ] Endpoint `POST /api/uploaded_documents` (multipart) → renvoie l'IRI ; whitelist MIME explicite (PDF + images) ; hors whitelist → 422.
|
||||
**Tests à prévoir** : PHPUnit — MIME hors whitelist → 422 ; MIME valide → IRI + ligne persistée ; checksum calculé.
|
||||
**Tips** : générique et réutilisable (autres modules la consommeront). Antivirus / S3 / purge = HP (§ 9).
|
||||
|
||||
### 1.3 — Migrer le schéma BDD M4 (carrier + sous-tables)
|
||||
**Position** : 1.3 • Suit : infra upload • Précède : QualimatCarrier
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Contexte** : créer le schéma du répertoire (entité éditable distincte du référentiel `qualimat_carrier`).
|
||||
**Spec liée** : [`spec-back.md § 3.2`](./spec-back.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Migration namespace racine `DoctrineMigrations`, **postérieure** à `Version20260612160000`.
|
||||
- [ ] Tables `carrier`, `carrier_address`, `carrier_contact`, `carrier_price` + FK (`qualimat_carrier`, `uploaded_document`, `client`, `client_address`, `supplier`, `supplier_address`, `site`, `user`).
|
||||
- [ ] `certification_type` **nullable** (null seulement en cas LIOT) + CHECK enum ; CHECK `container_type`, `direction`, `pricing_unit`, `price_state`, branches Prix client/fournisseur.
|
||||
- [ ] Index partiel `uq_carrier_name_active` (LOWER(name), WHERE non archivé & non supprimé).
|
||||
- [ ] **`COMMENT ON COLUMN` sur TOUTES les colonnes** (règle n°12) + helper Timestampable/Blamable. `ColumnsHaveSqlCommentTest` vert.
|
||||
- [ ] `make db-reset` passe ; schéma conforme.
|
||||
**Tests à prévoir** : `make db-reset` OK ; `ColumnsHaveSqlCommentTest` vert ; index partiel présent.
|
||||
**Tips** : PK `BIGINT` (cohérence module Transport) — à confirmer vs `INT`.
|
||||
|
||||
### 1.4 — Exposer `QualimatCarrier` (lecture seule) + endpoint recherche
|
||||
**Position** : 1.4 • Suit : migration • Précède : entités Carrier*
|
||||
**Tag** : Backend • **Effort** : S
|
||||
**Contexte** : la saisie assistée du nom (RG-4.01) a besoin d'un endpoint de recherche sur le référentiel QUALIMAT, aujourd'hui alimenté en console mais non exposé.
|
||||
**Spec liée** : [`spec-back.md § 4.7`](./spec-back.md) · RG-4.01
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Entité `QualimatCarrier` (lecture seule) mappée sur la table existante `qualimat_carrier` (aucune écriture exposée).
|
||||
- [ ] `GET /api/qualimat_carriers?search=` : fuzzy sur `name` (+ `siret`), **seulement `is_active = true`**, tri `name`, paginé (règle n°13).
|
||||
- [ ] **Security** `is_granted('transport.carriers.view')`. Champs exposés : `id, siret, name, address, postalCode, city, phone, department, status, validityDate, isActive`.
|
||||
**Tests à prévoir** : PHPUnit — recherche ne renvoie que les actifs ; pagination Hydra ; 403 sans permission.
|
||||
**Tips** : DoR — ERP-39 mergé. Ne pas toucher la commande de sync.
|
||||
|
||||
### 1.5 — Créer entités `Carrier*` + repos + `ApiResource` + `CarrierProvider`
|
||||
**Position** : 1.5 • Suit : QualimatCarrier • Précède : CarrierProcessor
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Contexte** : poser les entités, le contrat de sérialisation (groupes) et la lecture (liste + détail).
|
||||
**Spec liée** : [`spec-back.md § 3.3 / 3.4 / 4.0 / 4.1 / 4.2`](./spec-back.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Entités `Carrier`, `CarrierAddress`, `CarrierContact`, `CarrierPrice` (`#[Auditable]`, `TimestampableBlamableTrait`), repos Doctrine.
|
||||
- [ ] `ApiResource` Carrier : `GetCollection` + `Get` + `Post` + `Patch` avec `security` (§ 3.3) ; **pas de Delete**.
|
||||
- [ ] Groupes de sérialisation : `carrier:read`, `carrier:item:read`, `qualimat:read`, embed `client:read`/`client_address:read`/`supplier:read`/`supplier_address:read`/`site:read` au détail (3 maillons § 4.0 — ⚠ les adresses de l'onglet Prix sont des entités `ClientAddress`/`SupplierAddress` distinctes).
|
||||
- [ ] `CarrierProvider` paginé (`ApiPlatform\Doctrine\Orm\Paginator`) ; liste **sans cloisonnement site** (§ 2.3) ; anti-N+1 (§ 2.11).
|
||||
- [ ] Piège booléen `isArchived` : `#[SerializedName('isArchived')]` sur le getter.
|
||||
**Tests à prévoir** : liste exclut archivés par défaut ; `?includeArchived=true` ; enveloppe Hydra ; `isArchived` présent dans le JSON.
|
||||
**Tips** : miroir `Supplier`/`Provider`. Pas d'onglet Comptabilité (≠ M2/M3).
|
||||
|
||||
### 1.6 — Implémenter `CarrierProcessor`
|
||||
**Position** : 1.6 • Suit : entités • Précède : sous-ressource Adresses
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Contexte** : logique d'écriture du formulaire principal (POST/PATCH) : normalisation, champs conditionnels, archivage.
|
||||
**Spec liée** : [`spec-back.md § 4.3 / 4.4 / 7`](./spec-back.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] **RG-4.01** : POST avec `qualimatCarrier` → `certificationType=QUALIMAT` + FK persistée ; cas LIOT : `name='LIOT'` ⇒ `certificationType` non requis, `liotPlates` accepté.
|
||||
- [ ] **RG-4.02** : `certificationType='AUTRE'` sans `dischargeDocument` → **422** (`#[Assert\Callback]`).
|
||||
- [ ] **RG-4.03** : `isChartered=true` sans `indexationRate`/`containerType`/`volumeM3` → **422**.
|
||||
- [ ] **RG-4.13** : normalisation (`name` UPPER, contacts Capitalize, phones digits, email lower, `liotPlates`).
|
||||
- [ ] **RG-4.12** : doublon `name` (actifs) → **409**.
|
||||
- [ ] **RG-4.14** : PATCH `isArchived` exige `transport.carriers.archive` (Admin) ; mode strict (403 sinon).
|
||||
**Tests à prévoir** : PHPUnit sur chaque RG ci-dessus (cf. § 8.1).
|
||||
**Tips** : `CarrierFieldNormalizer` miroir `SupplierFieldNormalizer`.
|
||||
|
||||
### 1.7 — Sous-ressource Adresses (`carrier_address`)
|
||||
**Position** : 1.7 • Suit : CarrierProcessor • Précède : Contacts
|
||||
**Tag** : Backend • **Effort** : S
|
||||
**Spec liée** : [`spec-back.md § 4.5`](./spec-back.md) · RG-4.05→4.07
|
||||
**Critères d'acceptation** :
|
||||
- [ ] `POST /api/carriers/{id}/addresses`, `PATCH`/`DELETE /api/carrier_addresses/{id}` (security `manage`).
|
||||
- [ ] **RG-4.06** : `postalCode` matche `^[0-9]{4,5}$` (autocomplete ville = front).
|
||||
- [ ] **RG-4.05** : si affrété, adresse obligatoire (Pays/CP/Ville/Adresse) — validation conditionnelle.
|
||||
**Tests à prévoir** : PHPUnit — CP invalide → 422 ; adresse affrété incomplète → 422.
|
||||
**Tips** : RG-4.07 (bouton Valider masqué si QUALIMAT) = front, back accepte le PATCH.
|
||||
|
||||
### 1.8 — Sous-ressource Contacts (`carrier_contact`)
|
||||
**Position** : 1.8 • Suit : Adresses • Précède : Prix
|
||||
**Tag** : Backend • **Effort** : S
|
||||
**Spec liée** : [`spec-back.md § 4.5`](./spec-back.md) · RG-4.08
|
||||
**Critères d'acceptation** :
|
||||
- [ ] `POST /api/carriers/{id}/contacts`, `PATCH`/`DELETE /api/carrier_contacts/{id}` (security `manage`).
|
||||
- [ ] **RG-4.08** : bloc valide si ≥ 1 champ rempli (CHECK `chk_carrier_contact_filled` + Processor) ; **max 2 téléphones**.
|
||||
**Tests à prévoir** : PHPUnit — contact vide → 422 ; 1 champ → 200.
|
||||
**Tips** : miroir contacts M2/M3.
|
||||
|
||||
### 1.9 — Sous-ressource Prix (`carrier_price`) + RG branches
|
||||
**Position** : 1.9 • Suit : Contacts • Précède : Export
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Spec liée** : [`spec-back.md § 4.5 / 7`](./spec-back.md) · RG-4.09→4.11
|
||||
**Critères d'acceptation** :
|
||||
- [ ] `POST /api/carriers/{id}/prices`, `PATCH`/`DELETE /api/carrier_prices/{id}` (security `manage`).
|
||||
- [ ] **RG-4.10** (CLIENT) : `client`, `clientDeliveryAddress`, `departureSite` requis ; `clientDeliveryAddress` doit appartenir au `client` → sinon 422.
|
||||
- [ ] **RG-4.11** (FOURNISSEUR) : `supplier`, `supplierSupplyAddress`, `deliverySite` requis ; `supplierSupplyAddress` appartient au `supplier` → sinon 422.
|
||||
- [ ] Communs obligatoires : `containerType`, `pricingUnit`, `price`, `priceState` ; CHECK branches respectées.
|
||||
**Tests à prévoir** : PHPUnit — branche CLIENT/FOURNISSEUR incomplète → 422 ; adresse étrangère → 422.
|
||||
**Tips** : « Adresse départ/livraison 86/17/82 » = `Site` (FK) ; livraison client = `ClientAddress`, appro = `SupplierAddress` (relations ORM partagées).
|
||||
|
||||
### 1.10 — Export XLSX (répertoire + onglet Prix regroupé)
|
||||
**Position** : 1.10 • Suit : Prix • Précède : Tests PHPUnit
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Spec liée** : [`spec-back.md § 4.6`](./spec-back.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] `GET /api/carriers/export.xlsx` : transporteurs affichés (mêmes filtres) ; colonnes § 4.6.
|
||||
- [ ] `GET /api/carriers/{id}/prices/export.xlsx` : tableau Prix regroupé Benne / Fond Mouvant (colonnes docx p.10).
|
||||
- [ ] Controllers custom `#[Route(priority: 1)]` (conflit API Platform `{id}`) ; `Content-Disposition`.
|
||||
**Tests à prévoir** : PHPUnit — 200 + en-tête fichier ; respect des filtres.
|
||||
**Tips** : PhpSpreadsheet déjà présent.
|
||||
|
||||
### 1.11 — Tests PHPUnit RG-4.01→4.14 + capture contrat JSON (DoD)
|
||||
**Position** : 1.11 • Suit : Export • Précède : Page Répertoire
|
||||
**Tag** : Backend • **Effort** : M
|
||||
**Spec liée** : [`spec-back.md § 4.0.bis / 8.1`](./spec-back.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Matrice RG-4.01→4.14 couverte (§ 8.1) + RBAC par rôle (Compta/Usine → 403).
|
||||
- [ ] `CarrierSerializationContractTest` : capture JSON réel **liste + détail** ; `prices[].client`/`.supplier`/sites **embarqués** (pas IRI) ; `qualimatCarrier` embarqué ; `isArchived` présent.
|
||||
- [ ] Anti-N+1 liste ; pagination Hydra ; audit (`entity_type='Carrier'`) ; `AuditableEntitiesHaveI18nLabelTest` vert.
|
||||
- [ ] `CarrierFixtures` idempotent (§ 8.4) : transporteur QUALIMAT (validité passée), AUTRE+décharge, affrété, LIOT, complet (contacts/adresses/prix CLIENT+FOURNISSEUR), 1 archivé.
|
||||
**Tests à prévoir** : suite complète `make test` verte.
|
||||
**Tips** : coller les JSON capturés dans § 4.0.bis (DoD avant front).
|
||||
|
||||
### 1.12 — Page Répertoire `/carriers` (datatable, filtres, export)
|
||||
**Position** : 1.12 • Suit : Tests back • Précède : Page Ajouter
|
||||
**Tag** : Frontend • **Effort** : M
|
||||
**Spec liée** : [`spec-front.md § Datatable / Filtres`](./spec-front.md) · Figma `1132-45377`
|
||||
**Critères d'acceptation** :
|
||||
- [ ] `<MalioDataTable>` + `usePaginatedList<Carrier>({url:'/carriers'})` ; colonnes Nom / Certification / Date de validité / Dernière activité.
|
||||
- [ ] **RG-4.04** : date de validité QUALIMAT < aujourd'hui → **fond rouge**.
|
||||
- [ ] Filtres (`search`, `certificationType`, `includeArchived`) → `setFilters` (page 1) ; **état 100 % local** (règle n°6).
|
||||
- [ ] Boutons « + Ajouter » (si `manage`) / « Filtrer » / « Exporter » (XLSX) ; clic ligne → Consultation.
|
||||
**Tests à prévoir** : Vitest — `usePaginatedList` (Hydra, exclusion archivés).
|
||||
**Tips** : `useApi()` obligatoire ; pas de persistance URL.
|
||||
|
||||
### 1.13 — Page Ajouter `/carriers/new` (layout, onglets, formulaire principal POST)
|
||||
**Position** : 1.13 • Suit : Répertoire • Précède : Saisie assistée QUALIMAT
|
||||
**Tag** : Frontend • **Effort** : M
|
||||
**Spec liée** : [`spec-front.md § Écran Ajouter / Formulaire principal`](./spec-front.md) · Figma node `1132-45382` (Ajouter – Qualimat)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Layout + barre d'onglets `Qualimat · Adresses · Contacts · Prix` ; validation incrémentale (onglet suivant accessible après validation).
|
||||
- [ ] Formulaire principal (Nom, Liste certification, Affréter, …) → `POST /api/carriers` ; succès → bascule onglet + champs readonly.
|
||||
- [ ] `useFormErrors` : mapping 422 inline par champ ; `{ toast:false }`.
|
||||
**Tests à prévoir** : Vitest — `useCarrierForm` (workflow par onglet, POST principal).
|
||||
**Tips** : miroir `useSupplierForm`/`useProviderForm`.
|
||||
|
||||
### 1.14 — Saisie assistée QUALIMAT + champs conditionnels
|
||||
**Position** : 1.14 • Suit : Page Ajouter • Précède : Onglet Adresses
|
||||
**Tag** : Frontend • **Effort** : M
|
||||
**Spec liée** : [`spec-front.md § Formulaire principal / Onglet Qualimat`](./spec-front.md) · RG-4.01→4.03 · Figma nodes `1132-50717` (Affréter), `1132-50982` (AUTRE→Décharge), `1132-45593` (LIOT)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] **RG-4.01** : saisie du nom → `GET /api/qualimat_carriers?search=` → modal « Êtes-vous sûr… » → copie Nom + certification (`QUALIMAT`, readonly) + adresse + FK conservée.
|
||||
- [ ] **Cas LIOT** : nom `LIOT` → champ immatriculations seul, autres masqués.
|
||||
- [ ] **RG-4.02** : certification `AUTRE` → champ Décharge visible **et obligatoire** (upload).
|
||||
- [ ] **RG-4.03** : « Affréter » coché → indexation / benne-fond mouvant / volume visibles et obligatoires.
|
||||
**Tests à prévoir** : Vitest — affichage conditionnel (Affréter, AUTRE, LIOT) ; copie QUALIMAT.
|
||||
**Tips** : `useQualimatSearch()` ; `useUpload()` (ticket 1.19) pour la décharge.
|
||||
|
||||
### 1.15 — Onglet Adresses (autocomplete BAN)
|
||||
**Position** : 1.15 • Suit : Saisie QUALIMAT • Précède : Onglet Contacts
|
||||
**Tag** : Frontend • **Effort** : M
|
||||
**Spec liée** : [`spec-front.md § Onglet Adresses`](./spec-front.md) · RG-4.05→4.07 · Figma node `1132-45670`
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Bloc adresse (Pays/CP/Ville/Adresse/complément) → `PATCH /api/carriers/{id}/addresses`.
|
||||
- [ ] **RG-4.06** : `useAddressAutocomplete()` (BAN) — ville auto depuis CP, dégradé texte libre.
|
||||
- [ ] **RG-4.05** : champs préremplis si QUALIMAT ; obligatoires si affrété. **RG-4.07** : pas de bouton Valider si QUALIMAT.
|
||||
**Tests à prévoir** : Vitest — autocomplete nominal + dégradé (réutilisation M1/M2/M3).
|
||||
**Tips** : ne pas réécrire `useAddressAutocomplete()`.
|
||||
|
||||
### 1.16 — Onglet Contacts
|
||||
**Position** : 1.16 • Suit : Adresses • Précède : Onglet Prix
|
||||
**Tag** : Frontend • **Effort** : S
|
||||
**Spec liée** : [`spec-front.md § Onglet Contacts`](./spec-front.md) · RG-4.08 · Figma node `1132-45756`
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Blocs contact (Nom/Prénom/Fonction/Téléphone x1-2/Email) → `PATCH /api/carriers/{id}/contacts`.
|
||||
- [ ] **RG-4.08** : « + Nouveau contact » bloqué tant que le bloc courant est vide ; suppression avec modal.
|
||||
**Tests à prévoir** : Vitest — règle « ≥ 1 champ », max 2 téléphones.
|
||||
**Tips** : `mapViolationsToRecord` par ligne (pattern collections M1/M2/M3).
|
||||
|
||||
### 1.17 — Onglet Prix (Client/Fournisseur, sites)
|
||||
**Position** : 1.17 • Suit : Contacts • Précède : Consultation/Modification
|
||||
**Tag** : Frontend • **Effort** : M
|
||||
**Spec liée** : [`spec-front.md § Onglet Prix`](./spec-front.md) · RG-4.09→4.11 · Figma node `1132-45859`
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Radio `direction` (Client/Fournisseur) → bascule des champs (**RG-4.09**).
|
||||
- [ ] **RG-4.10** (Client) : Client + Adresse de livraison (du client) + Adresse de départ (86/17/82).
|
||||
- [ ] **RG-4.11** (Fournisseur) : Fournisseur + Adresse d'approvisionnement + Adresse de livraison (86/17/82).
|
||||
- [ ] Communs : Benne/FM, Forfait/Tonne, Prix (`MalioInputAmount`), État du prix → `PATCH /api/carriers/{id}/prices`.
|
||||
**Tests à prévoir** : Vitest — bascule Client/Fournisseur, champs requis.
|
||||
**Tips** : selects clients/fournisseurs/sites via endpoints existants (security élargie § 4.8).
|
||||
|
||||
### 1.18 — Pages Consultation + Modification
|
||||
**Position** : 1.18 • Suit : Onglet Prix • Précède : Upload/i18n
|
||||
**Tag** : Frontend • **Effort** : M
|
||||
**Spec liée** : [`spec-front.md § Consultation / Modification`](./spec-front.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Consultation readonly (ouvre sur Adresses) ; flèche retour ; « Modifier » (si `manage`) ; « Archiver » (Admin) → PATCH `isArchived`.
|
||||
- [ ] Onglet Prix consultation = tableau regroupé Benne/FM + bouton Exporter (XLSX).
|
||||
- [ ] Modification = mêmes formulaires, champs pré-remplis, PATCH partiel par onglet.
|
||||
**Tests à prévoir** : Vitest — `useCarrier(id)` peuple les écrans depuis une seule réponse ; visibilité boutons par permission.
|
||||
**Tips** : « Restaurer » remplace « Archiver » sur un archivé.
|
||||
|
||||
### 1.19 — Upload front (`useUpload`) + i18n + libellés audit
|
||||
**Position** : 1.19 • Suit : Consultation/Modification • Précède : —
|
||||
**Tag** : Frontend • **Effort** : S
|
||||
**Spec liée** : [`spec-back.md § 2.7 / 2.8`](./spec-back.md) · [`spec-front.md § Composables`](./spec-front.md)
|
||||
**Critères d'acceptation** :
|
||||
- [ ] Composable `useUpload()` : `POST /api/uploaded_documents` (multipart) → IRI posée sur `carrier.dischargeDocument` (RG-4.02).
|
||||
- [ ] Clés i18n : libellés certification, sidebar (`sidebar.transport.*`), **libellés audit** `audit.entity.transport_carrier/carrieraddress/carriercontact/carrierprice`.
|
||||
- [ ] `<MalioInputUpload>` (exception documentée si type non couvert).
|
||||
**Tests à prévoir** : Vitest — `useUpload` (succès + erreur MIME).
|
||||
**Tips** : `AuditableEntitiesHaveI18nLabelTest` exige les clés audit.
|
||||
|
||||
---
|
||||
|
||||
## Actions Lesstime (à exécuter au feu vert de Matthieu)
|
||||
|
||||
1. `create-group` projectId 6, title « M4 — Répertoire transporteurs » → récupérer l'`id`.
|
||||
2. `create-task` ×19 (statut `Prêt à dev` = 6, priorité Moyen=2, effort dans la description), dans l'ordre 1.1 → 1.19 :
|
||||
- Tickets **1.1 → 1.11** (Backend, tag `3`) → **assigné à Matthieu**.
|
||||
- Tickets **1.12 → 1.19** (Frontend, tag `2`) → **assigné à Tristan**.
|
||||
3. Mettre à jour le frontmatter des specs (`lesstime_taskgroup_id`) + lien du groupe.
|
||||
|
||||
> Au push : récupérer les `userId` via `list-users` (Matthieu = `5` selon le référentiel ; Tristan à confirmer) pour renseigner l'assignation à la création.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Prompt d'implémentation — M5 · ERP-181 (1.1) — Scaffolder le module Logistique + RBAC
|
||||
|
||||
Projet **Starseed** (modular monolith DDD). Tâche **back**. Lis d'abord `CLAUDE.md` + `.claude/rules/architecture.md` + `.claude/rules/backend.md`, puis la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 2.1, § 5).
|
||||
|
||||
## Mission
|
||||
Créer le **nouveau module `Logistique`** et poser son socle RBAC, **avant toute entité**. Aucun écran fonctionnel ici, juste le squelette + permissions + sidebar + 3 miroirs RBAC.
|
||||
|
||||
## Étapes
|
||||
1. Scaffolder via le skill projet **`create-module`** : `src/Module/Logistique/` avec `Domain/ Application/ Infrastructure/` et `LogistiqueModule.php` :
|
||||
- `const string ID = 'logistique'` ; `const string LABEL = 'Logistique'` ; `const bool REQUIRED = false`.
|
||||
- `permissions()` retourne :
|
||||
- `['code' => 'logistique.weighing_tickets.view', 'label' => 'Voir les tickets de pesée']`
|
||||
- `['code' => 'logistique.weighing_tickets.manage', 'label' => 'Créer / modifier les tickets de pesée']`
|
||||
2. Enregistrer `LogistiqueModule::class` dans `config/modules.php`.
|
||||
3. Créer le layer front minimal `frontend/modules/logistique/nuxt.config.ts` (kebab-case, auto-détecté).
|
||||
4. Ajouter à `config/sidebar.php` une section/item « Logistique » :
|
||||
```php
|
||||
['label' => 'sidebar.logistique.weighing_tickets', 'to' => '/weighing-tickets',
|
||||
'icon' => 'mdi-scale', 'module' => 'logistique', 'permission' => 'logistique.weighing_tickets.view'],
|
||||
```
|
||||
+ la clé i18n `sidebar.logistique.*` dans `frontend/i18n/locales/fr.json`.
|
||||
5. **Règle ABSOLUE n°8 — 3 miroirs RBAC alignés ensemble** :
|
||||
- `config/sidebar.php` (item + permission ci-dessus),
|
||||
- `frontend/tests/e2e/_fixtures/personas.ts` (persona **Usine** gagne `weighing_tickets.view` + `manage` et `expectedAdminLinks` ; **Compta/Commerciale** : aucun accès),
|
||||
- `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` (miroir back du même persona).
|
||||
6. `make shell` → `php bin/console app:sync-permissions`.
|
||||
|
||||
## Garde-fous (règles ABSOLUES)
|
||||
- `declare(strict_types=1);` partout ; commentaires **en français**, code en anglais.
|
||||
- Permission au format `module.resource.action` snake_case.
|
||||
- Ne PAS créer d'entité ni de migration ici (ticket 1.2).
|
||||
- Pas de hardcode sidebar côté front : elle vient de `/api/sidebar`.
|
||||
|
||||
## Vérification
|
||||
- `make test` (les tests Architecture ne cassent pas).
|
||||
- `make php-cs-fixer-allow-risky`.
|
||||
- `GET /api/modules` retourne `logistique` ; `GET /api/sidebar` : item présent pour Admin/Bureau/Usine, **absent** pour Compta/Commerciale.
|
||||
- Les 3 miroirs RBAC sont cohérents (sinon test E2E faux positif).
|
||||
@@ -0,0 +1,28 @@
|
||||
# Prompt d'implémentation — M5 · ERP-182 (1.2) — Migrer le schéma M5
|
||||
|
||||
Projet **Starseed**. Tâche **back / migration**. Lis `CLAUDE.md` (règles n°11 et n°12), `.claude/rules/backend.md` (§ Migrations) et la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 3.2, § 3.2.bis, § 2.5, § 2.7).
|
||||
|
||||
## Mission
|
||||
Écrire **une** migration Doctrine au namespace racine `DoctrineMigrations` (`migrations/VersionYYYYMMDDHHMMSS.php`, postérieure aux existantes) qui crée tout le schéma M5.
|
||||
|
||||
## Étapes
|
||||
1. **`site.code`** : `ALTER TABLE site ADD COLUMN code VARCHAR(8)` **NULLABLE** → backfill `UPDATE site SET code = LEFT(postal_code, 2) WHERE code IS NULL` → index unique `uq_site_code` (tolère les NULL multiples Postgres).
|
||||
- ⚠ **NE PAS poser `SET NOT NULL` ici.** Sur `make db-reset`, les fixtures `SitesFixtures` insèrent des sites via l'ORM, qui ne connaît `code` que si la propriété est mappée sur l'entité `Site.php` (fait en ERP-183) → sinon `INSERT` sans `code` → violation `NOT NULL` → db-reset plante. Le `NOT NULL` est posé en **ERP-183** (2ᵉ migration) une fois `Site::code` mappé + peuplé. Cf. spec § 2.5.
|
||||
2. Table **`weighing_ticket_counter (site_id PK → site, last_value INT NOT NULL DEFAULT 0)`** (séquence numéro par site, RG-5.02).
|
||||
3. Table **`weighbridge_dsd_counter (site_id PK → site, last_value INT NOT NULL DEFAULT 0)`** (compteur DSD par site, RG-5.04).
|
||||
4. Table **`weighing_ticket`** : copier le DDL de la spec § 3.2 (colonnes `site_id`, `number`, contrepartie `counterparty_type`/`client_id`/`supplier_id`/`other_label`, `immatriculation`/`plate_free_format`, `empty_*`, `full_*`, `net_weight`, `deleted_at` + 4 colonnes Timestampable/Blamable).
|
||||
- Convention `INT GENERATED BY DEFAULT AS IDENTITY`, `TIMESTAMP(0) WITHOUT TIME ZONE` (§ 2.2).
|
||||
- CHECK : `counterparty_type`, `empty_mode`/`full_mode`, et les **3 branches contrepartie** (RG-5.03).
|
||||
- Index unique `(site_id, number)` + index FK (`site`, `client`, `supplier`, `deleted_at`, `created_by`, `updated_by`).
|
||||
5. **Règle ABSOLUE n°12** : `COMMENT ON COLUMN` (FR, ≤ 200 car., sémantique + RG) sur **chaque** colonne créée — cf. échantillon § 3.2.bis. Les 4 colonnes Timestampable/Blamable via `addStandardTimestampableBlamableComments($schema, 'weighing_ticket')`. Bonus `COMMENT ON TABLE`.
|
||||
6. Écrire `down()` symétrique (drop tables + drop colonne `site.code`).
|
||||
|
||||
## Garde-fous
|
||||
- Noms de colonnes **en minuscules** (Postgres).
|
||||
- FK cross-module (`user`, `client`, `supplier`, `site`) → la migration **doit** vivre au namespace racine (règle n°11), sinon `make db-reset` casse l'ordre.
|
||||
- `ON DELETE` : `site` = RESTRICT, `client`/`supplier` = RESTRICT, `created_by`/`updated_by` = SET NULL, compteurs = CASCADE.
|
||||
|
||||
## Vérification
|
||||
- `make db-reset` puis `make migration-migrate` (BDD fraîche) → OK.
|
||||
- `make test` : `ColumnsHaveSqlCommentTest` **vert** (aucune colonne `public` sans `col_description`).
|
||||
- `make php-cs-fixer-allow-risky`.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Prompt d'implémentation — M5 · ERP-183 (1.3) — Entité WeighingTicket + repository + contrat sérialisation
|
||||
|
||||
Projet **Starseed**. Tâche **back**. Lis `CLAUDE.md`, `.claude/rules/backend.md` (Audit, Timestampable/Blamable, Serialization) et la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 3.3, § 4.0, § 2.11). Prérequis : ERP-182 mergé.
|
||||
|
||||
## Mission
|
||||
Créer l'entité Doctrine **`WeighingTicket`** + son `#[ApiResource]` (Get / GetCollection / Post / Patch) + le repository, avec le **contrat de sérialisation posé une seule fois** (read-groups sur chaque propriété affichée — RETEX M1→M4).
|
||||
|
||||
## ⚠ Finaliser `site.code` (dette laissée par ERP-182 — à faire EN PREMIER)
|
||||
ERP-182 a créé `site.code` **nullable** (sinon `db-reset` cassait). Ici on le rend obligatoire, maintenant que l'ORM peut le remplir :
|
||||
1. Mapper la propriété `code` sur l'entité `src/Module/Sites/Domain/Entity/Site.php` (colonne + getter/setter + groupes de sérialisation cohérents avec les autres champs `site:*`).
|
||||
2. Peupler `code` (86 / 17 / 82) dans `SitesFixtures` **et** dans `SeedE2ECommand.php` (sites seedés).
|
||||
3. Ajuster les tests Sites en collision d'unicité : ex. `SiteApiTest` qui crée un site CP `86000` → `code` 86 entre en collision avec la fixture Châtellerault (86) → adapter le CP/code du test.
|
||||
4. **2ᵉ petite migration** (namespace racine) : `ALTER TABLE site ALTER COLUMN code SET NOT NULL;` (+ `COMMENT ON COLUMN` si pas déjà posé).
|
||||
5. `make db-reset` + `make test` doivent rester verts.
|
||||
|
||||
## Étapes — WeighingTicket
|
||||
1. `src/Module/Logistique/Domain/Entity/WeighingTicket.php` (squelette spec § 3.3) :
|
||||
- `#[Auditable]`, `use TimestampableBlamableTrait`, `implements TimestampableInterface, BlamableInterface`.
|
||||
- Relations ORM **partagées** (PAS d'import de logique) : `Client` (M1), `Supplier` (M2), `Site` (Sites) en ManyToOne.
|
||||
- Propriétés : `number`, `site`, `counterpartyType`, `client`, `supplier`, `otherLabel`, `immatriculation`, `plateFreeFormat`, `empty*` (date/weight/dsd/mode/manualNumber), `full*` (idem), `netWeight`.
|
||||
2. **Read-groups (3 maillons § 4.0)** :
|
||||
- `weighing_ticket:read` = champs liste (`number`, `counterpartyType`, `client`, `supplier`, `otherLabel`, `displayDate`, `netWeight`, `plateFreeFormat`, timestamps).
|
||||
- `weighing_ticket:item:read` = détail (`empty*`, `full*`, `site`, `immatriculation`).
|
||||
- Contextes des opérations exactement comme § 3.3 (inclure `client:read`, `supplier:read`, `site:read`, `default:read`).
|
||||
3. Getter calculé **`displayDate`** (= `fullDate ?? emptyDate`) annoté `weighing_ticket:read`.
|
||||
4. Booléen **`plateFreeFormat`** : exposer via getter + `#[SerializedName('plateFreeFormat')]` (piège #3 M1 — la clé doit sortir dans le JSON).
|
||||
5. Sécurité opérations : GET = `is_granted('logistique.weighing_tickets.view')` ; POST/PATCH = `...manage`. **Pas de Delete, pas d'archive.** Provider/Processor référencés (implémentés en ERP-184/185).
|
||||
6. Contraintes `#[Assert\*]` avec **messages FR** ; `Assert\Length.max` aligné sur les colonnes ORM.
|
||||
7. **Libellé i18n audit** : `audit.entity.logistique_weighingticket` dans `frontend/i18n/locales/fr.json`.
|
||||
8. `src/Module/Logistique/Infrastructure/Doctrine/DoctrineWeighingTicketRepository.php`.
|
||||
|
||||
## Garde-fous
|
||||
- `declare(strict_types=1);` ; commentaires FR.
|
||||
- Ne JAMAIS importer une classe d'un autre **module** pour de la logique — seules les entités de référence (Client/Supplier/Site) sont consommées en relation ORM (toléré M1→M4).
|
||||
- Pagination gérée par le Provider (ERP-185) — ne pas désactiver la pagination.
|
||||
|
||||
## Vérification
|
||||
- `make test` : `EntitiesAreTimestampableBlamableTest`, `AuditableEntitiesHaveI18nLabelTest`, `EntityConstraintsHaveFrenchMessageTest` **verts**.
|
||||
- `make php-cs-fixer-allow-risky`.
|
||||
- (La capture JSON réelle du contrat est faite en ERP-187.)
|
||||
@@ -0,0 +1,33 @@
|
||||
# Prompt d'implémentation — M5 · ERP-184 (1.4) — Pesée pont bascule (stub + DSD + endpoint)
|
||||
|
||||
Projet **Starseed**. Tâche **back**. Lis `CLAUDE.md`, `.claude/rules/backend.md` et la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 2.6, § 2.7, § 4.2). Prérequis : ERP-182.
|
||||
|
||||
## Mission
|
||||
Implémenter la pesée déclenchée par les boutons « Pesée bascule » / « Pesée manuelle » : **stub** (pas de liaison matérielle au M5) + allocateur DSD + endpoint API.
|
||||
|
||||
## Étapes
|
||||
1. Contrat `Logistique\Domain\Contract\WeighbridgeReaderInterface` :
|
||||
```php
|
||||
public function read(SiteInterface $site): WeighbridgeReading; // {weight:int kg, dsd:int}
|
||||
```
|
||||
+ `WeighbridgeUnavailableException`.
|
||||
2. Impl `Logistique\Infrastructure\Weighbridge\RandomWeighbridgeReader` : `weight = random_int(10000, 50000)` (RG-5.06), `dsd = DsdAllocator::next($site)`.
|
||||
3. `DsdAllocator` (service) : compteur DSD **par site** sur `weighbridge_dsd_counter`, incrément avec **verrou ligne `SELECT ... FOR UPDATE`** dans une transaction.
|
||||
- AUTO : incrémente et renvoie la nouvelle valeur.
|
||||
- MANUAL : `dsd = dernier dsd du site + 1` (RG-5.04).
|
||||
4. Endpoint **`POST /api/weighbridge_readings`** — ressource virtuelle (DTO `WeighbridgeReadingInput`/`Output`) + Processor dédié, **pas de controller Symfony** :
|
||||
- `{ "mode": "AUTO" }` → `{ weight, dsd, mode }` (site courant via `CurrentSiteProviderInterface`).
|
||||
- `{ "mode": "MANUAL", "weight": <int>, "manualNumber": "<str>" }` → `{ weight, dsd, manualNumber, mode }`.
|
||||
- Erreur `WeighbridgeUnavailableException` → **HTTP 503** explicite « Pont bascule indisponible — passez en pesée manuelle » (RG-5.06).
|
||||
- Sécurité `is_granted('logistique.weighing_tickets.manage')`.
|
||||
5. Le `dsd` renvoyé est **prévisionnel** : noter en commentaire que l'attribution autoritaire est refaite à la création du ticket (ERP-185).
|
||||
|
||||
## Garde-fous
|
||||
- `declare(strict_types=1);` ; commentaires FR.
|
||||
- Consommer `CurrentSiteProviderInterface` (contrat Sites) — pas d'import de logique d'un autre module.
|
||||
- Pas de controller sous `/api` (API Platform).
|
||||
|
||||
## Vérification
|
||||
- `make test` : `WeighbridgeReaderStubTest` (poids ∈ [10000,50000] + chemin erreur → 503), `DsdAllocatorTest` (AUTO incrémente / MANUAL = dernier+1 / par site).
|
||||
- `make php-cs-fixer-allow-risky`.
|
||||
- Appel manuel `POST /api/weighbridge_readings {AUTO}` (token Usine) → poids + dsd cohérents.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Prompt d'implémentation — M5 · ERP-185 (1.5) — Provider + Processor WeighingTicket
|
||||
|
||||
Projet **Starseed**. Tâche **back**. Lis `CLAUDE.md`, `.claude/rules/backend.md` (Pagination, RBAC, Validation) et la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 4.3, § 4.4, § 2.5, § 2.9, § 2.8, § 6, § 2.3). Prérequis : ERP-183, ERP-184.
|
||||
|
||||
## Mission
|
||||
Implémenter la logique métier d'écriture (Processor) et de lecture (Provider) du ticket de pesée.
|
||||
|
||||
## Étapes — `WeighingTicketProcessor` (POST/PATCH)
|
||||
1. **Site courant** : résoudre via `CurrentSiteProviderInterface` → `site_id` (à la création).
|
||||
2. **Numéro `{siteCode}-TP-{NNNN}`** (RG-5.02) : à la création, incrémenter `weighing_ticket_counter` du site avec **`SELECT ... FOR UPDATE`**, formater `%04d`. Numéro **immuable** au PATCH (RG-5.09).
|
||||
3. **DSD autoritaire** : (ré)attribuer `empty_dsd`/`full_dsd` via `DsdAllocator` (verrou) si pesée AUTO (RG-5.04).
|
||||
4. **RG-5.03** (contrepartie) : `#[Assert\Callback]` sur l'entité → selon `counterpartyType`, exiger `client` / `supplier` / `otherLabel` et forcer les autres à `null` (messages FR, `->atPath()` sur le bon champ).
|
||||
5. **RG-5.05** : `net_weight = full_weight - empty_weight` (plein − vide) si les 2 poids présents, sinon `null`.
|
||||
6. **RG-5.01 / RG-5.10** : `WeighingTicketFieldNormalizer` (service appelé avant validation) — `immatriculation` trim+UPPER ; si `!plateFreeFormat` reformate `XX-000-XX` et **rejette en 422** si invalide ; `otherLabel` trim.
|
||||
7. `site` immuable au PATCH (RG-5.09).
|
||||
|
||||
## Étapes — `WeighingTicketProvider` (GET)
|
||||
8. Liste **paginée** via `ApiPlatform\Doctrine\Orm\Paginator` (jamais d'array brut — règle n°13).
|
||||
9. **Cloisonnement par site courant** (§ 2.3) : appliquer le `SiteScopedQueryExtension` existant (ou filtrer sur le site courant).
|
||||
10. Query params : `?search=` (sur `number`, nom client/fournisseur, `other_label`, `immatriculation`), tri `displayDate` (défaut `number DESC`).
|
||||
11. Anti-N+1 : fetch-join `client`/`supplier`/`site` (ManyToOne sûrs).
|
||||
|
||||
## Garde-fous
|
||||
- `declare(strict_types=1);` ; commentaires FR ; messages de validation **FR**.
|
||||
- Toutes les violations 422 portent un `propertyPath` aligné sur les noms de champs (consommé par le front `useFormErrors`).
|
||||
- Pas de controller ; pas de `paginationEnabled: false`.
|
||||
|
||||
## Vérification
|
||||
- `make test` (les tests dédiés sont écrits en ERP-187) : au minimum `CollectionsArePaginatedTest` **vert**.
|
||||
- `make php-cs-fixer-allow-risky`.
|
||||
- Smoke manuel : `POST /api/weighing_tickets` (Usine) → numéro `86-TP-0001` attribué, `net_weight` calculé ; second POST même site → `86-TP-0002`.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Prompt d'implémentation — M5 · ERP-186 (1.6) — Export XLSX des tickets de pesée
|
||||
|
||||
Projet **Starseed**. Tâche **back**. Lis `CLAUDE.md`, `.claude/rules/backend.md` et la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 4.5). Prérequis : ERP-185.
|
||||
|
||||
## Mission
|
||||
Endpoint d'export XLSX de **toute la liste** des tickets de pesée (bouton « Exporter »).
|
||||
|
||||
## Étapes
|
||||
1. Endpoint **`GET /api/weighing_tickets/export.xlsx`** : opération API Platform dédiée avec provider renvoyant un binaire (`Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, `Content-Disposition: attachment`).
|
||||
2. Respecter le **site courant** + les filtres actifs (mêmes critères que la liste, mais **sans pagination** → export complet).
|
||||
3. Colonnes : Numéro, Contrepartie (type + nom Client/Fournisseur/Autre), Date, Immatriculation, Poids vide, Poids plein, **Poids net**, DSD vide, DSD plein.
|
||||
4. Sécurité `is_granted('logistique.weighing_tickets.view')`.
|
||||
5. Whitelister cette opération dans `CollectionsArePaginatedTest::EXCLUDED` (export complet légitime).
|
||||
|
||||
## Garde-fous
|
||||
- `declare(strict_types=1);` ; commentaires FR.
|
||||
- Utiliser le helper XLSX standard du projet (cf. exports M1→M4) — ne pas réinventer.
|
||||
- Pas de controller custom sous `/api` sans `priority: 1` (préférer une opération API Platform).
|
||||
|
||||
## Vérification
|
||||
- `make test` : test de l'export (colonnes + filtrage site) + `CollectionsArePaginatedTest` vert.
|
||||
- `make php-cs-fixer-allow-risky`.
|
||||
- Téléchargement manuel → fichier ouvrable, colonnes correctes, poids net = plein − vide.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Prompt d'implémentation — M5 · ERP-187 (1.7) — Tests PHPUnit RG-5.01→5.10 + capture contrat JSON
|
||||
|
||||
Projet **Starseed**. Tâche **back / tests**. Lis `CLAUDE.md`, `.claude/rules/testing.md` et la spec : `docs/specs/M5-tickets-pesee/spec-back.md` (§ 8, § 4.0.bis). Prérequis : ERP-183 → ERP-186 mergés.
|
||||
|
||||
## Mission
|
||||
Couvrir les RG du M5 par des tests PHPUnit et **capturer la réponse JSON réelle** (DoD) à coller dans la spec avant le démarrage front.
|
||||
|
||||
## Étapes
|
||||
1. **`WeighingTicketSerializationContractTest`** : seeder un ticket complet (contrepartie Client, pesée vide + plein), capturer le JSON **liste** + **détail** (via une variable d'env de dump, cf. pattern M4 `CARRIER_DOD_DUMP`). Vérifier les **4 pièges** :
|
||||
- `client` / `supplier` sortent en **objet embarqué**, pas en IRI nu ;
|
||||
- `plateFreeFormat` présent dans le JSON ;
|
||||
- `number` présent et formaté `{siteCode}-TP-{NNNN}` ;
|
||||
- `netWeight` = `full - empty` (plein − vide).
|
||||
→ **Coller le JSON capturé dans `spec-back.md § 4.0.bis`** (feu vert front).
|
||||
2. `WeighingTicketNumberingTest` : numéro par site, unicité, concurrence (`FOR UPDATE`), immuabilité au PATCH.
|
||||
3. `DsdAllocatorTest` : AUTO incrémente / MANUAL = dernier+1 / compteur par site.
|
||||
4. `WeighbridgeReaderStubTest` : poids ∈ [10000,50000] ; `WeighbridgeUnavailableException` → 503 (RG-5.06).
|
||||
5. `NetWeightTest` : plein − vide ; `null` si une pesée manque (RG-5.05).
|
||||
6. `CounterpartyValidationTest` : RG-5.03 (chaque branche valide + rejets des incohérences).
|
||||
7. `ImmatriculationNormalizationTest` : masque `XX-000-XX`, `plateFreeFormat`, 422 si invalide (RG-5.01).
|
||||
8. **RBAC** : Admin/Bureau/Usine OK ; Compta/Commerciale → 403 ; anonyme → 401.
|
||||
|
||||
## Garde-fous
|
||||
- `declare(strict_types=1);` ; fixtures dédiées sous `tests/Fixtures/`.
|
||||
- **Pas de test E2E** (règle d'or) — PHPUnit uniquement.
|
||||
- Ne pas casser les tests Architecture existants.
|
||||
|
||||
## Vérification
|
||||
- `make test` **vert** (suite complète, dont Architecture).
|
||||
- `spec-back.md § 4.0.bis` contient le JSON RÉEL avec les 4 pièges marqués verts.
|
||||
- `make php-cs-fixer-allow-risky`.
|
||||
@@ -0,0 +1,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 |
|
||||
@@ -0,0 +1,692 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M6
|
||||
nom: "Catalogue produit"
|
||||
ecran: produits
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0.1
|
||||
date_redaction: 2026-06-24
|
||||
# Historique :
|
||||
# V0.1 (2026-06-24) — Spec back initiale. Restitution + précisions back du docx fonctionnel
|
||||
# « M6-produit-V0 » (V0, 15/06/2026, validation client en attente).
|
||||
# Décisions Matthieu (24/06) :
|
||||
# (1) Produit logé dans le module EXISTANT `Catalog` (pas de nouveau module) ;
|
||||
# item sidebar dans la section « Administration », sous « Répertoire transporteurs ».
|
||||
# (2) « Type de stockage » : référentiel minimal `StorageType` créé maintenant (provisoire,
|
||||
# en attendant la liste définitive d'Aurore), seedé avec la liste Figma (node 1503-34285).
|
||||
# (3) « Code produit » = « Numéro » de la liste : MÊME champ, saisi, UNIQUE global (409 doublon).
|
||||
# (4) « État du produit » : Achat / Vendu / Autre, multi-select, AU MOINS 1 requis
|
||||
# (corrige l'incohérence « Autre » vs « Aucun » du docx).
|
||||
# (5) PÉRIMÈTRE V0 = CRUD produit classique uniquement. Les onglets « Fournisseurs » et
|
||||
# « Clients » sont des PLACEHOLDERS « en cours de développement » (dépendent d'un module
|
||||
# Contrat inexistant) — hors périmètre, tracés HP-M6-01.
|
||||
|
||||
# === LIENS ===
|
||||
spec_front: ./spec-front.md
|
||||
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1503-34285&p=f&m=dev"
|
||||
trace_fonctionnelle: "uploads/M6-produit-V0.pdf (V0, 15/06/2026, validation client en attente)"
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_project_id: 6
|
||||
lesstime_taskgroup_id: 36 # M6 — Catalogue produit (ERP-197 → ERP-207)
|
||||
statut_global: pret_a_dev
|
||||
|
||||
# === DÉPENDANCES AMONT ===
|
||||
depend_de:
|
||||
- Catalog # Module hôte (REQUIRED). Category + CategoryType (ajout du type PRODUIT) + nouveau StorageType + Product
|
||||
- Sites # Site (relation ManyToMany product↔site) + filtrage des types de stockage par site
|
||||
- Core # User, Role, Permission, Audit, JWT
|
||||
- Shared # TimestampableBlamableTrait + Subscriber (ERP-52) + CategoryInterface
|
||||
---
|
||||
|
||||
# Spec back — Module 6 : Catalogue produit
|
||||
|
||||
## 1. Contexte
|
||||
|
||||
Cette spec **complète et précise** la [spec front V0.1](./spec-front.md) (docx `M6-produit-V0`, V0 du 15/06/2026, **validation client en attente**) 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-6.01 → RG-6.10), tests, hors-périmètre.
|
||||
|
||||
**Module cible** : module **EXISTANT `Catalog`** (`src/Module/Catalog/`) — DÉCISION Matthieu (24/06). Le docx parle d'un « Module 7 — Catalogue produit » rattaché à « l'Administration », mais le projet possède déjà un module `Catalog` (`ID = 'catalog'`, `REQUIRED = true`) qui porte `Category` / `CategoryType`. « Catalogue produit » y a sa place naturelle : on **n'ajoute pas de module**, on ajoute l'entité `Product` (+ le référentiel `StorageType`) au module `Catalog`. L'item de menu vit dans la section **Administration** de la sidebar, **sous « Répertoire transporteurs »** (cf. § 5.3).
|
||||
|
||||
> **RETEX obligatoire (M1→M5)** : ~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 M6. On réutilise le pattern Provider/Processor + normalisation serveur + Timestampable/Blamable + audit i18n + soft delete posé aux modules précédents, et la taxonomie `Category` codée (ERP-78).
|
||||
|
||||
**Dépendances déjà en place sur `develop`** :
|
||||
- `Catalog` → `Category` (taxonomie codée, soft delete, `CategoryInterface`) + `CategoryType` (référentiel statique, types CLIENT / FOURNISSEUR / PRESTATAIRE seedés). Le type **`PRODUIT` n'est PAS encore seedé** — le M6 l'ajoute (§ 2.5).
|
||||
- `Sites` → 3 sites Châtellerault (`code` 86) / Saint-Jean (17) / Pommevic (82) ; `Site.code` déjà mappé ; `SiteInterface`.
|
||||
- `Shared` → `TimestampableBlamableTrait` + `Subscriber` (ERP-52).
|
||||
- `Core` → User, Role, Permission, Audit, JWT.
|
||||
|
||||
## 1.bis Remise en question du docx (incohérences relevées + résolutions)
|
||||
|
||||
> Le docx V0 est volontairement léger. Voici les points ambigus ou contradictoires relevés à la relecture, et la décision retenue (validée Matthieu 24/06). **Toute la spec qui suit applique ces décisions.**
|
||||
|
||||
| # | Point du docx | Problème | Décision retenue |
|
||||
|---|---|---|---|
|
||||
| C1 | « Module 7 — Catalogue produit » / « Module Administration » | Le projet n'a pas de « Module 7 » ni de module « Administration » ; un module `Catalog` existe déjà. | **Produit logé dans `Catalog`** ; item sidebar dans la **section Administration**, sous « Répertoire transporteurs » (§ 2.1 / § 5.3). |
|
||||
| C2 | Colonnes liste = `Nom`, `Numéro`, `Catégorie` ; formulaire = `Nom`, `Code produit`, `Catégorie` | « Numéro » (liste) vs « Code produit » (formulaire) : 2 noms pour quoi ? | **Même champ** : `code` (= « Numéro » = « Code produit »), **saisi**, **unique global**, 409 sur doublon (RG-6.01). La colonne liste « Numéro » affiche `code`. |
|
||||
| C3 | « État du produit » : Multi-select **obligatoire**, valeurs *Achat / Vendu / Autre* | Les onglets parlent de *Achat / Vendu / **Aucun*** → « Autre » ≠ « Aucun ». Et « obligatoire » + « Aucun » se contredisent. | **Enum `PURCHASE` / `SALE` / `OTHER`**, multi-select, **≥ 1 obligatoire** (RG-6.02). « Aucun » des onglets = « ni Achat ni Vendu » (donc `OTHER` seul). |
|
||||
| C4 | « Type de stockage » : « *liste fournie par Aurore* en fonction des sites » | Aucun référentiel de stockage n'existe en base ; la vraie liste est en attente. | **Référentiel minimal `StorageType` créé maintenant** (provisoire), seedé avec la liste Figma (node 1503-34285) ; options **filtrées par les sites sélectionnés** (RG-6.06, § 2.4). À re-seeder quand Aurore livre la liste/le mapping site définitifs (HP-M6-02). |
|
||||
| C5 | « Catégorie produit » : « Liste des catégories produit » | Le type `PRODUIT` n'est pas seedé ; aucune catégorie produit n'existe. | Le M6 **seede le `CategoryType` PRODUIT** + quelques `Category` produit, et le select est **filtré `?typeCode=PRODUIT`** (RG-6.05, § 2.5). |
|
||||
| C6 | « Fabriqué » / « Contient de la mélasse » : « apparaît si État = Vendu » | Comportement front only ? Que stocke-t-on si l'état n'est plus Vendu ? | Booléens **conditionnés à `SALE`** : saisis seulement si l'état contient `SALE`, sinon **forcés `false` serveur** (RG-6.03). |
|
||||
| C7 | RBAC : Admin = Tout, tous les autres rôles = Non | Très restrictif (admin-only). Confirmé ? | **Confirmé** : `catalog.products.view` / `.manage` attribués **au seul rôle Admin** (§ 5.2). |
|
||||
| C8 | Onglets « Fournisseurs » / « Clients » (contrats, prestation de triage, contrats TAF) | Référencent une notion de **Contrat** (client/fournisseur) **inexistante** dans le code. | **Hors périmètre V0** : onglets rendus en **placeholder « en cours de développement »** (comme les autres onglets non encore dev). Tracé HP-M6-01 (§ 9). |
|
||||
|
||||
## 2. Décisions d'archi
|
||||
|
||||
### 2.1 Entité `Product` dans le module `Catalog`
|
||||
|
||||
Ajout au module **`Catalog`** (pas de nouveau module — C1) :
|
||||
- Entité racine **`Product`** sous `src/Module/Catalog/Domain/Entity/Product.php`.
|
||||
- Référentiel **`StorageType`** sous `src/Module/Catalog/Domain/Entity/StorageType.php` (§ 2.4).
|
||||
- Permissions `catalog.products.view` / `catalog.products.manage` ajoutées à `CatalogModule::permissions()` (§ 5.1).
|
||||
- Pas de nouveau layer front (le module `catalog` n'a pas de layer dédié — les écrans admin du Catalog vivent dans le shell `frontend/app/` / `frontend/shared/`, comme `/admin/categories`). Route Nuxt : `/admin/products` (cf. spec-front).
|
||||
|
||||
**Référentiels cross-module consommés en relation ORM partagée (PAS d'import de logique)** — comme M2→M5 : `Product` référence `Site` (Sites) via une **relation ORM** (ManyToMany). Donnée de référence partagée, aucun service/repository d'un autre module appelé. `Category` et `StorageType` appartiennent au **même** module `Catalog` → relations internes classiques.
|
||||
|
||||
### 2.2 IDs — convention `INT` (alignée Catalog / Core)
|
||||
|
||||
`Product` et `StorageType` s'alignent sur la convention du module `Catalog` : **`INT GENERATED BY DEFAULT AS IDENTITY`**. Horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le `TimestampableBlamableTrait` mappe `datetime_immutable`).
|
||||
|
||||
### 2.3 État du produit — multi-valeur `states` (C3 / RG-6.02)
|
||||
|
||||
`états` est un **multi-select** : un produit peut être à la fois `PURCHASE` et `SALE`. Modélisation : colonne **`states JSONB NOT NULL DEFAULT '[]'`** (tableau de chaînes), valeurs autorisées `PURCHASE` / `SALE` / `OTHER`, **≥ 1** (Callback + CHECK de non-vacuité).
|
||||
|
||||
> **Alternative écartée** : 3 colonnes booléennes (`is_purchase`/`is_sale`/`is_other`). Plus simple à requêter mais s'éloigne de la sémantique « multi-select » et multiplie les colonnes. Le JSONB est retenu pour la fidélité au champ unique du docx ; si un besoin de filtrage SQL fin apparaît (HP), on bascule sur une table de jonction `product_state`.
|
||||
|
||||
Pilotage des champs conditionnels (RG-6.03) : `manufactured` et `containsMolasses` ne sont **saisissables que si `states` contient `SALE`** ; sinon forcés `false` côté serveur (Processor) — pas de state machine.
|
||||
|
||||
### 2.4 Référentiel `StorageType` (C4 / RG-6.06) — PROVISOIRE
|
||||
|
||||
> **Décision Matthieu (24/06)** : créer un **référentiel minimal** en attendant la liste/mapping définitifs d'Aurore. Seed = liste Figma (node 1503-34285).
|
||||
|
||||
- Entité `StorageType` (`Catalog`) : `id`, `code` (slug MAJUSCULE stable, unique), `label` (FR affiché), relation **`sites` ManyToMany → Site** (sur quels sites ce type de stockage est disponible).
|
||||
- **Seed initial (10 valeurs, Figma)** : Boisseau, Boisseau dosage, Case, Cellule, Container, Cuve mélasse, Stockage big bag, Stockage palette, Tas, Zone. **Provisoirement rattachés aux 3 sites** (86/17/82) tant qu'Aurore n'a pas précisé le mapping réel par site.
|
||||
- Le champ produit « Type de stockage » est un **multi-select filtré par les sites sélectionnés** dans le formulaire : `GET /api/storage_types?siteId[]=…` ne renvoie que les types disponibles sur ces sites (RG-6.06).
|
||||
- **Provisoire** : codes, libellés et mapping site sont à revalider/re-seeder à réception de la liste Aurore (HP-M6-02). Référentiel en **lecture seule** au M6 (pas de CRUD admin du StorageType — HP-M6-03).
|
||||
|
||||
### 2.5 Catégorie produit — type `PRODUIT` (C5 / RG-6.05)
|
||||
|
||||
- Le M6 **seede le `CategoryType` `PRODUIT`** (code `PRODUIT`, label « Produit ») : ajout dans **`CategoryTypeFixtures::TYPES`** ET dans une **migration de seed** (miroir dev/prod, comme CLIENT/FOURNISSEUR/PRESTATAIRE — cf. `CategoryTypeFixtures` docblock).
|
||||
- Le M6 seede aussi quelques **`Category` de type PRODUIT** (ex. provisoires : « Céréales », « Oléagineux », « Aliments du bétail », « Engrais ») pour alimenter le select. Codes auto-générés par `CategoryCodeGenerator` (slug MAJUSCULE stable).
|
||||
- `Product.category` = **ManyToOne `Category`** (obligatoire). Le select du formulaire est **filtré `?typeCode=PRODUIT`** (provider Category existant — filtre `typeCode` déjà supporté). Lecture du référentiel via `catalog.categories.read_ref` ou `.view` (déjà en place).
|
||||
|
||||
> **Garde-fou** : on **ne contraint pas** en base que `category` soit de type PRODUIT (le filtrage est applicatif via le select + une validation `#[Assert\Callback]` côté Processor qui rejette une catégorie non-PRODUIT en 422). Justification : éviter un couplage SQL fragile au référentiel type.
|
||||
|
||||
### 2.6 Audit & traces temporelles
|
||||
|
||||
Pattern Starseed standard (miroir M1→M5) :
|
||||
- `#[Auditable]` sur `Product`. Pas de champ sensible (password/token) → pas d'`#[AuditIgnore]`.
|
||||
- Audit des relations (`category`, `sites`, `storageTypes`) tracé automatiquement (ManyToMany inclus).
|
||||
- `Product implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait` (4 colonnes standard).
|
||||
- **Libellé i18n** (règle ABSOLUE backend — `AuditableEntitiesHaveI18nLabelTest`) : ajouter `audit.entity.catalog_product` dans `frontend/i18n/locales/fr.json` (clé = `strtolower(module)` + `_` + `strtolower(Entity)` = `catalog_product`).
|
||||
- `StorageType` = référentiel **statique** en lecture seule → **pas** de Timestampable/Blamable, **pas** `#[Auditable]` (whitelister dans `EntitiesAreTimestampableBlamableTest::EXCLUDED`, miroir `CategoryType`).
|
||||
|
||||
### 2.7 Soft delete préparé ; pas de Delete exposé au M6
|
||||
|
||||
Le docx M6 ne prévoit **ni archivage ni suppression** (actions = + Ajouter / Exporter / Filtrer). On **n'expose pas** de `Delete`. On prépare néanmoins une colonne `deleted_at` (soft delete technique) **non exposée** (cohérent avec `Category` et le pattern M5). Le provider exclut par défaut les produits soft-deleted.
|
||||
|
||||
## 3. Modèle de données
|
||||
|
||||
### 3.1 Diagramme
|
||||
|
||||
```
|
||||
+------------------+ +------------------------+
|
||||
| site (Sites) | | category_type (Catalog)| + seed type PRODUIT (§ 2.5)
|
||||
+------------------+ +------------------------+
|
||||
^ ^ ^
|
||||
| | | (ManyToMany existant)
|
||||
product_ | | storage_type_ |
|
||||
site | | site +------------------+
|
||||
| | | category | (type PRODUIT)
|
||||
+------------------+ +------------------+ +------------------+
|
||||
| product | | storage_type | ^
|
||||
| id (PK) | | id (PK) | | category_id (FK, NOT NULL)
|
||||
| code (UNIQUE) | | code (UNIQUE) |----------+
|
||||
| name | | label | (product.category ManyToOne)
|
||||
| states (JSONB) | +------------------+
|
||||
| manufactured | ^
|
||||
| contains_molasses| | product_storage_type (ManyToMany)
|
||||
| category_id (FK) |--------+
|
||||
| deleted_at |
|
||||
| created_at/by … |
|
||||
+------------------+
|
||||
^ ^
|
||||
| | product_site (ManyToMany) / product_storage_type (ManyToMany)
|
||||
+---+
|
||||
```
|
||||
|
||||
Tables de jonction : `product_site (product_id, site_id)`, `product_storage_type (product_id, storage_type_id)`, `storage_type_site (storage_type_id, site_id)`.
|
||||
|
||||
### 3.2 Migration Doctrine — SQL Postgres (illustratif)
|
||||
|
||||
Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/VersionYYYYMMDDHHMMSS.php` (postérieur aux migrations existantes).
|
||||
|
||||
> **Même justification qu'aux M1→M5** : FK cross-module (`user`, `site`, `category`) → le namespace modulaire casserait l'ordre sur `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 `addStandardTimestampableBlamableComments`.
|
||||
|
||||
```sql
|
||||
-- =====================================================================
|
||||
-- Référentiel des types de stockage (provisoire — § 2.4 / RG-6.06)
|
||||
-- =====================================================================
|
||||
CREATE TABLE storage_type (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
code VARCHAR(40) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX uq_storage_type_code ON storage_type (code);
|
||||
|
||||
CREATE TABLE storage_type_site (
|
||||
storage_type_id INT NOT NULL REFERENCES storage_type(id) ON DELETE CASCADE,
|
||||
site_id INT NOT NULL REFERENCES site(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (storage_type_id, site_id)
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- Table principale `product`
|
||||
-- =====================================================================
|
||||
CREATE TABLE product (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
code VARCHAR(50) NOT NULL, -- = « Numéro » liste, unique global (RG-6.01)
|
||||
name VARCHAR(255) NOT NULL,
|
||||
states JSONB NOT NULL DEFAULT '[]'::jsonb, -- PURCHASE|SALE|OTHER, >=1 (RG-6.02)
|
||||
manufactured BOOLEAN NOT NULL DEFAULT FALSE, -- saisi si SALE, sinon false (RG-6.03)
|
||||
contains_molasses BOOLEAN NOT NULL DEFAULT FALSE, -- saisi si SALE, sinon false (RG-6.03)
|
||||
category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT, -- type PRODUIT (RG-6.05)
|
||||
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE, -- soft delete, non exposé (§ 2.7)
|
||||
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_product_states_not_empty CHECK (jsonb_array_length(states) >= 1)
|
||||
);
|
||||
-- Unicité GLOBALE du code parmi les actifs (soft delete toléré) — index partiel.
|
||||
CREATE UNIQUE INDEX uq_product_code_active ON product (code) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_product_category ON product (category_id);
|
||||
CREATE INDEX idx_product_deleted_at ON product (deleted_at);
|
||||
CREATE INDEX idx_product_created_by ON product (created_by);
|
||||
CREATE INDEX idx_product_updated_by ON product (updated_by);
|
||||
|
||||
-- =====================================================================
|
||||
-- Jonctions produit ↔ sites / types de stockage
|
||||
-- =====================================================================
|
||||
CREATE TABLE product_site (
|
||||
product_id INT NOT NULL REFERENCES product(id) ON DELETE CASCADE,
|
||||
site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
|
||||
PRIMARY KEY (product_id, site_id)
|
||||
);
|
||||
CREATE TABLE product_storage_type (
|
||||
product_id INT NOT NULL REFERENCES product(id) ON DELETE CASCADE,
|
||||
storage_type_id INT NOT NULL REFERENCES storage_type(id) ON DELETE RESTRICT,
|
||||
PRIMARY KEY (product_id, storage_type_id)
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- Seed du type de catégorie PRODUIT (§ 2.5) — miroir CategoryTypeFixtures
|
||||
-- =====================================================================
|
||||
INSERT INTO category_type (code, label) VALUES ('PRODUIT', 'Produit')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
```
|
||||
|
||||
### 3.2.bis Commentaires SQL obligatoires (échantillon)
|
||||
|
||||
```php
|
||||
$this->addSql("COMMENT ON TABLE product IS 'Produits du catalogue (M6 Catalog) — état Achat/Vendu/Autre, sites de disponibilité, catégorie produit, types de stockage.'");
|
||||
$this->addSql("COMMENT ON COLUMN product.code IS 'Code produit (= « Numéro » de la liste), saisi, unique global parmi les actifs (RG-6.01). Index partiel uq_product_code_active.'");
|
||||
$this->addSql("COMMENT ON COLUMN product.states IS 'États du produit (JSON) : sous-ensemble non vide de PURCHASE|SALE|OTHER, multi-select (RG-6.02). Pilote les champs conditionnels.'");
|
||||
$this->addSql("COMMENT ON COLUMN product.manufactured IS '« Fabriqué » : saisi uniquement si states contient SALE, sinon forcé false serveur (RG-6.03).'");
|
||||
$this->addSql("COMMENT ON COLUMN product.contains_molasses IS '« Contient de la mélasse » : saisi uniquement si states contient SALE, sinon forcé false serveur (RG-6.03).'");
|
||||
$this->addSql("COMMENT ON COLUMN product.category_id IS 'Catégorie produit (FK category, type PRODUIT) — obligatoire, validée applicativement (RG-6.05).'");
|
||||
$this->addSql("COMMENT ON COLUMN product.deleted_at IS 'Horodatage de suppression logique (soft delete) — non exposé au M6 ; la liste exclut les produits supprimés (§ 2.7).'");
|
||||
$this->addSql("COMMENT ON TABLE storage_type IS 'Référentiel des types de stockage (PROVISOIRE, en attente liste Aurore) — Boisseau, Silo, Tas… (RG-6.06).'");
|
||||
$this->addSql("COMMENT ON COLUMN storage_type.code IS 'Code stable MAJUSCULE du type de stockage (ex. TAS, CUVE_MELASSE). Unique (uq_storage_type_code).'");
|
||||
$this->addSql("COMMENT ON COLUMN storage_type.label IS 'Libellé FR affiché du type de stockage (ex. « Cuve mélasse »).'");
|
||||
// + COMMENT ON COLUMN sur les tables de jonction (product_site, product_storage_type, storage_type_site)
|
||||
$this->addStandardTimestampableBlamableComments($schema, 'product');
|
||||
```
|
||||
|
||||
### 3.3 Entité `Product` — squelette (extrait)
|
||||
|
||||
Pattern jumeau de `Category` (`#[Auditable]`, `TimestampableBlamableTrait`, soft delete). **Chaque propriété affichée porte un read-group** (RETEX M1).
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\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\Catalog\Infrastructure\ApiPlatform\State\Processor\ProductProcessor;
|
||||
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\ProductProvider;
|
||||
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository;
|
||||
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagée (§ 2.1)
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('catalog.products.view')",
|
||||
normalizationContext: ['groups' => ['product:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||
provider: ProductProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('catalog.products.view')",
|
||||
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||
provider: ProductProvider::class,
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('catalog.products.manage')",
|
||||
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['product:write']],
|
||||
processor: ProductProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('catalog.products.manage')",
|
||||
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['product:write']],
|
||||
provider: ProductProvider::class,
|
||||
processor: ProductProcessor::class,
|
||||
),
|
||||
// Pas de Delete au M6 (docx) ; soft delete préparé non exposé (§ 2.7).
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineProductRepository::class)]
|
||||
#[ORM\Table(name: 'product')]
|
||||
#[Auditable]
|
||||
class Product implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
#[Groups(['product:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
/** Code produit (= « Numéro » liste), unique global, saisi (RG-6.01). */
|
||||
#[ORM\Column(length: 50)]
|
||||
#[Assert\NotBlank(message: 'Le code produit est obligatoire.')]
|
||||
#[Assert\Length(max: 50, maxMessage: 'Le code produit ne peut pas dépasser {{ limit }} caractères.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'Le nom du produit est obligatoire.')]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le nom du produit ne peut pas dépasser {{ limit }} caractères.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private ?string $name = null;
|
||||
|
||||
/** États (multi-select) ⊆ {PURCHASE, SALE, OTHER}, ≥ 1 (RG-6.02). */
|
||||
#[ORM\Column(type: 'json')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')]
|
||||
#[Assert\All([new Assert\Choice(choices: ['PURCHASE', 'SALE', 'OTHER'], message: 'État de produit invalide.')])]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private array $states = [];
|
||||
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private bool $manufactured = false; // saisi si SALE, sinon false (RG-6.03)
|
||||
|
||||
#[ORM\Column(name: 'contains_molasses', options: ['default' => false])]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private bool $containsMolasses = false; // saisi si SALE, sinon false (RG-6.03)
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Category::class)]
|
||||
#[ORM\JoinColumn(name: 'category_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
#[Assert\NotNull(message: 'La catégorie produit est obligatoire.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private ?Category $category = null; // type PRODUIT, validé Callback (RG-6.05)
|
||||
|
||||
/** @var Collection<int, Site> Sites de disponibilité (≥ 1, RG-6.04). */
|
||||
#[ORM\ManyToMany(targetEntity: Site::class)]
|
||||
#[ORM\JoinTable(name: 'product_site')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un site.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private Collection $sites;
|
||||
|
||||
/** @var Collection<int, StorageType> Types de stockage (≥ 1, filtrés par sites — RG-6.06). */
|
||||
#[ORM\ManyToMany(targetEntity: StorageType::class)]
|
||||
#[ORM\JoinTable(name: 'product_storage_type')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de stockage.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private Collection $storageTypes;
|
||||
|
||||
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
|
||||
private ?\DateTimeImmutable $deletedAt = null; // soft delete, non exposé (§ 2.7)
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->sites = new ArrayCollection();
|
||||
$this->storageTypes = new ArrayCollection();
|
||||
}
|
||||
|
||||
// RG-6.03 (champs conditionnels SALE) + RG-6.05 (catégorie de type PRODUIT)
|
||||
// + RG-6.06 (types de stockage ⊆ sites) : cohérence via #[Assert\Callback] (§ 7).
|
||||
// ... getters/setters ...
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠ `Site` appartient au module Sites — on consomme son read-group (`site:read`), **pas de logique inter-module** (§ 2.1). `Category` / `StorageType` sont dans le **même** module `Catalog`.
|
||||
|
||||
## 4. API REST (API Platform)
|
||||
|
||||
### 4.0 Contrat de sérialisation (RETEX M1 — section critique)
|
||||
|
||||
> **Leçon M1→M5** : pour **chaque champ affiché** (liste OU détail), les **3 maillons** : (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) | `product:read` + `category:read` + `site:read` + `storage_type:read` + `default:read` |
|
||||
| `Get` / `Post` / `Patch` (détail) | + `product:item:read` |
|
||||
|
||||
**LISTE — colonne datatable → maillons** (docx p.3 : Nom, Numéro, Catégorie) :
|
||||
|
||||
| Colonne affichée | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) |
|
||||
|---|---|---|---|
|
||||
| Nom | `name` ∈ `product:read` | ✅ | — |
|
||||
| Numéro | `code` ∈ `product:read` | ✅ | — |
|
||||
| Catégorie | `category` ∈ `product:read` (embed) | ✅ | `category:read` ✅ (affiche `category.name`) |
|
||||
|
||||
**DÉTAIL — maillons** : `states`, `manufactured`, `containsMolasses` ∈ `product:read` ; `sites` (embed `site:read`) + `storageTypes` (embed `storage_type:read`) ∈ `product:read` (ensembles **bornés** → embed autorisé, ne viole pas la règle n°13). Rien de spécifique en `product:item:read` au-delà des relations (tout le produit tient en liste) — `product:item:read` réservé si on ajoute des champs détail-only ultérieurement.
|
||||
|
||||
### 4.0.bis Réponse JSON de référence (DoD — CAPTURÉ sur l'API réelle, ERP-203)
|
||||
|
||||
> **Definition of Done** (miroir M2→M5) : créer un produit via `POST /api/products`, appeler `GET /api/products` (liste) ET `GET /api/products/{id}` (détail), **coller la réponse JSON réelle** ici. Toute donnée affichée par le front DOIT apparaître dans ce JSON. Pièges re-testés : `category` en **objet embarqué** (pas IRI nu) ; `sites` / `storageTypes` en **tableaux d'objets** (pas tableaux d'IRI) ; `states` en tableau de chaînes ; `manufactured` / `containsMolasses` présents (booléens). `skip_null_values` actif → ne pas présumer la présence des champs null.
|
||||
>
|
||||
> **Capture réelle** (ERP-203) : produit créé par un `POST` réel puis relu, via `ProductSerializationContractTest` (régénérable : `PRODUCT_DOD_DUMP=1` → `/tmp/product-dod-{list,detail}.json`). Valeurs ci-dessous reformatées avec des libellés lisibles ; **les clés sont celles de la réponse réelle**. Écarts notables vs l'esquisse initiale, à connaître côté front :
|
||||
> - La **LISTE porte déjà `sites` + `storageTypes` embarqués** (la propriété `product:read` est dans le contexte liste ET détail) : pas besoin d'un appel détail pour les obtenir.
|
||||
> - `category` embarque **sa collection `categoryTypes`** (utile pour vérifier le type PRODUIT côté front, RG-6.05) **plus ses métadonnées d'audit** (`createdAt`/`updatedAt`/`createdBy`/`updatedBy`).
|
||||
> - `createdBy` / `updatedBy` (produit et catégorie) sortent en **IRI** (`/api/me` pour l'utilisateur courant), pas en objet User embarqué.
|
||||
> - chaque `site` embarque l'**adresse complète** (`street`, `postalCode`, `city`, `color`, `fullAddress` — groupe `site:read`).
|
||||
> - un `StorageType` n'expose que `id` / `code` / `label` (sa relation `sites` n'est pas sérialisée — § 2.4).
|
||||
|
||||
**`GET /api/products` (LISTE)** — enveloppe Hydra AP4 (`member`/`totalItems`/`view`) :
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"@context": "/api/contexts/Product",
|
||||
"@id": "/api/products",
|
||||
"@type": "Collection",
|
||||
"totalItems": 1,
|
||||
"member": [
|
||||
{
|
||||
"@id": "/api/products/34",
|
||||
"@type": "Product",
|
||||
"id": 34,
|
||||
"code": "BLE-TENDRE-01",
|
||||
"name": "Blé tendre",
|
||||
"states": ["PURCHASE", "SALE"],
|
||||
"manufactured": true,
|
||||
"containsMolasses": true,
|
||||
"category": {
|
||||
"@id": "/api/categories/12",
|
||||
"@type": "Category",
|
||||
"id": 12,
|
||||
"name": "Céréales",
|
||||
"code": "CEREALES",
|
||||
"categoryTypes": [
|
||||
{ "@id": "/api/category_types/5", "@type": "CategoryType", "id": 5, "code": "PRODUIT", "label": "Produit" }
|
||||
],
|
||||
"createdAt": "2026-06-25T12:09:27+02:00",
|
||||
"updatedAt": "2026-06-25T12:09:27+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me"
|
||||
},
|
||||
"sites": [
|
||||
{
|
||||
"@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-25T11:32:33+02:00",
|
||||
"updatedAt": "2026-06-25T11:32:33+02:00",
|
||||
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
|
||||
}
|
||||
],
|
||||
"storageTypes": [
|
||||
{ "@id": "/api/storage_types/9", "@type": "StorageType", "id": 9, "code": "TAS", "label": "Tas" }
|
||||
],
|
||||
"createdAt": "2026-06-25T12:09:28+02:00",
|
||||
"updatedAt": "2026-06-25T12:09:28+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me"
|
||||
}
|
||||
],
|
||||
"view": { "@id": "/api/products?search=BLE-TENDRE-01", "@type": "PartialCollectionView" }
|
||||
}
|
||||
```
|
||||
|
||||
**`GET /api/products/34` (DÉTAIL)** — **même structure** que la ligne de liste (les `sites` / `storageTypes` sont déjà embarqués en liste ; `product:item:read` est réservé à d'éventuels champs détail-only ultérieurs) :
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"@context": "/api/contexts/Product",
|
||||
"@id": "/api/products/34",
|
||||
"@type": "Product",
|
||||
"id": 34,
|
||||
"code": "BLE-TENDRE-01",
|
||||
"name": "Blé tendre",
|
||||
"states": ["PURCHASE", "SALE"],
|
||||
"manufactured": true,
|
||||
"containsMolasses": true,
|
||||
"category": {
|
||||
"@id": "/api/categories/12",
|
||||
"@type": "Category",
|
||||
"id": 12,
|
||||
"name": "Céréales",
|
||||
"code": "CEREALES",
|
||||
"categoryTypes": [
|
||||
{ "@id": "/api/category_types/5", "@type": "CategoryType", "id": 5, "code": "PRODUIT", "label": "Produit" }
|
||||
],
|
||||
"createdAt": "2026-06-25T12:09:27+02:00",
|
||||
"updatedAt": "2026-06-25T12:09:27+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me"
|
||||
},
|
||||
"sites": [
|
||||
{
|
||||
"@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-25T11:32:33+02:00",
|
||||
"updatedAt": "2026-06-25T11:32:33+02:00",
|
||||
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
|
||||
}
|
||||
],
|
||||
"storageTypes": [
|
||||
{ "@id": "/api/storage_types/9", "@type": "StorageType", "id": 9, "code": "TAS", "label": "Tas" }
|
||||
],
|
||||
"createdAt": "2026-06-25T12:09:28+02:00",
|
||||
"updatedAt": "2026-06-25T12:09:28+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.1 Query params (LISTE)
|
||||
|
||||
| Param | Effet |
|
||||
|---|---|
|
||||
| `?page` / `?itemsPerPage` | pagination standard (10 / 25 / 50, défaut 10) |
|
||||
| `?search=` | recherche sur `code` et `name` |
|
||||
| `?categoryId=` ou `?categoryCode=` | filtre par catégorie (drawer « Filtrer », docx p.3) |
|
||||
| `?state=` | filtre par état (PURCHASE / SALE / OTHER) — drawer « Filtrer » |
|
||||
| `?siteId[]=` | filtre par site de disponibilité |
|
||||
| `?order[name]=asc` | tri (défaut : `name ASC`) |
|
||||
|
||||
Pagination obligatoire (règle ABSOLUE n°13) — provider ORM via `ApiPlatform\Doctrine\Orm\Paginator`, jamais d'array brut.
|
||||
|
||||
### 4.2 Référentiel `StorageType` — `GET /api/storage_types`
|
||||
|
||||
- Opérations : `GetCollection` + `Get` (lecture seule au M6 — § 2.4).
|
||||
- Sécurité : `is_granted('catalog.products.view')` (référentiel servant le formulaire produit). *(Si un autre rôle doit lire ce référentiel sans accès produit, ajouter une `read_ref` dédiée — non requis au M6 vu le RBAC admin-only.)*
|
||||
- **`?siteId[]=…`** : filtre les types disponibles sur les sites passés (alimente le multi-select « Type de stockage » filtré par les sites cochés — RG-6.06).
|
||||
- **`?pagination=false`** : échappatoire select (référentiel borné ≤ quelques dizaines) — règle frontend.
|
||||
- `normalizationContext: ['storage_type:read']` ; tri `label ASC`.
|
||||
|
||||
### 4.3 `POST /api/products` (création)
|
||||
|
||||
- Le client envoie : `code`, `name`, `states[]`, `manufactured`, `containsMolasses`, `category` (IRI), `sites[]` (IRI), `storageTypes[]` (IRI).
|
||||
- Le **Processor** (`ProductProcessor`) :
|
||||
1. Normalise `code` (trim + UPPER) et `name` (trim) — RG-6.07.
|
||||
2. Valide l'**unicité globale** du `code` parmi les actifs → **409** sur doublon (RG-6.01).
|
||||
3. Force `manufactured` / `containsMolasses` à `false` si `states` ne contient pas `SALE` (RG-6.03).
|
||||
4. Valide que `category` est de type **PRODUIT** (RG-6.05) et que `storageTypes ⊆` types disponibles sur les `sites` choisis (RG-6.06) → 422 sinon.
|
||||
- Réponse `201` avec le produit complet.
|
||||
|
||||
### 4.4 `PATCH /api/products/{id}` (modification)
|
||||
|
||||
- Mise à jour partielle, mêmes règles. Le **mode strict PATCH** s'applique (RETEX M1) : un champ hors-permission dans le payload = 403 global (ici un seul niveau `manage`, donc surface réduite).
|
||||
- Re-validation unicité `code` (en excluant le produit courant). Re-force des conditionnels (RG-6.03).
|
||||
|
||||
### 4.5 Export — `GET /api/products/export.xlsx`
|
||||
|
||||
- Exporte **toute la liste** des produits (docx : bouton « Exporter » → « Exporte toute la liste des produits »), filtres actifs appliqués.
|
||||
- Colonnes : Numéro (`code`), Nom, États (Achat/Vendu/Autre joints), Catégorie, Sites, Types de stockage, Fabriqué, Contient mélasse.
|
||||
- Génération via le helper XLSX standard projet (skill `xlsx`) — controller dédié (miroir `ClientExportController`) OU provider binaire ; **whitelisté pagination** (`EXCLUDED`) car export complet.
|
||||
|
||||
## 5. RBAC, module & sidebar
|
||||
|
||||
### 5.1 `CatalogModule::permissions()` — ajout
|
||||
|
||||
```php
|
||||
// Ajouts M6 (à insérer dans CatalogModule::permissions()) :
|
||||
['code' => 'catalog.products.view', 'label' => 'Voir les produits'],
|
||||
['code' => 'catalog.products.manage', 'label' => 'Gérer les produits (créer, éditer)'],
|
||||
```
|
||||
Synchronisation : `app:sync-permissions`.
|
||||
|
||||
### 5.2 Matrice rôle → permissions (docx p.3 — admin-only, C7)
|
||||
|
||||
| Rôle | `…products.view` | `…products.manage` |
|
||||
|---|:--:|:--:|
|
||||
| **Admin** | ✅ | ✅ |
|
||||
| **Bureau** | ❌ | ❌ |
|
||||
| **Compta** | ❌ | ❌ |
|
||||
| **Commerciale** | ❌ | ❌ |
|
||||
| **Usine** | ❌ | ❌ |
|
||||
|
||||
> Très restrictif : le Catalogue produit est **admin-only** (docx). Item sidebar masqué pour tous les autres rôles. (Si plus tard Bureau doit consulter, ajouter `catalog.products.view` à son rôle dans les 3 miroirs.)
|
||||
|
||||
### 5.3 Sidebar (`config/sidebar.php`)
|
||||
|
||||
Nouvel item dans la **section « Administration » existante**, placé **juste sous « Répertoire transporteurs »** (`/carriers`) — DÉCISION Matthieu (24/06) :
|
||||
|
||||
```php
|
||||
[
|
||||
'label' => 'sidebar.catalog.products',
|
||||
'to' => '/admin/products',
|
||||
'icon' => 'mdi:package-variant-closed',
|
||||
'module' => 'catalog',
|
||||
'permission' => 'catalog.products.view',
|
||||
],
|
||||
```
|
||||
|
||||
### 5.4 Règle ABSOLUE n°8 — 3 miroirs RBAC
|
||||
|
||||
Toute permission `catalog.products.*` doit être posée **simultanément** dans :
|
||||
1. `config/sidebar.php` (item + permission ci-dessus),
|
||||
2. `frontend/tests/e2e/_fixtures/personas.ts` (le persona **Admin** gagne `catalog.products.view/manage` + `expectedAdminLinks` ; les personas métier **ne** gagnent **rien**),
|
||||
3. `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` (miroir back du même persona Admin).
|
||||
|
||||
## 6. Normalisation serveur (RG-6.07)
|
||||
|
||||
`ProductFieldNormalizer` (miroir `CategoryProcessor` / `CarrierFieldNormalizer`), appelé par le Processor avant validation :
|
||||
- `code` → trim + UPPER (cohérent avec la stratégie de codes stables du Catalog).
|
||||
- `name` → trim (rejet 422 si vide après trim — RG-6.01/6.02 sur le name de Category, même garde-fou).
|
||||
|
||||
## 7. Règles de gestion (RG)
|
||||
|
||||
| RG | Source | Énoncé |
|
||||
|---|---|---|
|
||||
| **RG-6.01** | docx+back | `code` produit (= « Numéro » liste) obligatoire, **unique global** parmi les actifs, normalisé (trim/UPPER), **409** sur doublon. |
|
||||
| **RG-6.02** | docx+back | `states` = multi-select ⊆ {`PURCHASE`,`SALE`,`OTHER`}, **≥ 1** obligatoire (CHECK non-vide + `Assert\Count(min:1)`). |
|
||||
| **RG-6.03** | docx+back | « Fabriqué » et « Contient de la mélasse » saisissables **uniquement si `states` contient `SALE`** ; sinon forcés `false` serveur. |
|
||||
| **RG-6.04** | docx | `sites` (multi-select) obligatoire, **≥ 1** site. |
|
||||
| **RG-6.05** | docx+back | `category` obligatoire, limitée aux catégories de **type PRODUIT** (select filtré `?typeCode=PRODUIT` + validation Callback 422). |
|
||||
| **RG-6.06** | docx+back | `storageTypes` (multi-select) obligatoire, **≥ 1**, options **filtrées par les sites sélectionnés** ; référentiel `StorageType` **provisoire** (en attente Aurore). |
|
||||
| **RG-6.07** | back | Normalisation serveur : `code` trim+UPPER, `name` trim (§ 6). |
|
||||
| **RG-6.08** | back | « Modification » = même formulaire/mêmes règles que « Ajouter » ; bouton « Valider » → « Enregistrer » (docx p.7). Code & contraintes inchangés. |
|
||||
| **RG-6.09** | back | Liste : exclut par défaut les produits soft-deleted ; pas de Delete exposé (§ 2.7). |
|
||||
| **RG-6.10** | back | Onglets « Fournisseurs » / « Clients » = **hors périmètre V0** (placeholder), dépendent du module Contrat inexistant (HP-M6-01). |
|
||||
|
||||
Cohérence inter-champs (RG-6.03 / 6.05 / 6.06) implémentée via `#[Assert\Callback]` portant des messages FR + CHECK Postgres (non-vacuité `states`).
|
||||
|
||||
## 8. Tests (PHPUnit) — `make test`
|
||||
|
||||
- **`ProductSerializationContractTest`** : capture JSON liste + détail (DoD § 4.0.bis) ; `category`/`sites`/`storageTypes` embarqués (objets, pas IRI) ; `states` tableau ; booléens présents.
|
||||
- **`ProductCodeUniquenessTest`** : 409 sur doublon de `code` (actifs) ; réutilisation possible d'un code soft-deleted (index partiel).
|
||||
- **`ProductStatesValidationTest`** : ≥ 1 état (RG-6.02) ; valeurs hors enum rejetées.
|
||||
- **`ProductConditionalFieldsTest`** : `manufactured`/`containsMolasses` forcés `false` si pas `SALE` (RG-6.03).
|
||||
- **`ProductCategoryTypeTest`** : 422 si `category` n'est pas de type PRODUIT (RG-6.05).
|
||||
- **`ProductStorageTypeBySiteTest`** : 422 si un `storageType` n'est pas disponible sur les `sites` choisis (RG-6.06).
|
||||
- **RBAC** : Admin OK ; Bureau/Compta/Commerciale/Usine → 403 (view + manage).
|
||||
- **Architecture** (déjà en place, ne pas casser) : `ColumnsHaveSqlCommentTest`, `EntitiesAreTimestampableBlamableTest` (whitelister `StorageType`), `AuditableEntitiesHaveI18nLabelTest` (`catalog_product`), `CollectionsArePaginatedTest`, `EntityConstraintsHaveFrenchMessageTest`.
|
||||
|
||||
## 9. Hors périmètre (HP)
|
||||
|
||||
| Réf | Sujet |
|
||||
|---|---|
|
||||
| **HP-M6-01** | **Onglets « Fournisseurs » et « Clients »** du produit (liaison à des **contrats** client/fournisseur, « clients en prestation de triage », « contrats TAF »). Dépend d'un **module Contrat inexistant**. Rendus en **placeholder « en cours de développement »** au M6 (§ 1.bis C8, RG-6.10). À spécifier quand le module Contrat existera. |
|
||||
| **HP-M6-02** | Liste/mapping **définitifs des types de stockage par site** (fournis par Aurore). Re-seed du référentiel `StorageType` + révision du filtrage par site (§ 2.4). |
|
||||
| **HP-M6-03** | **CRUD admin du référentiel `StorageType`** (création/édition par un admin). Au M6 : lecture seule + seed. |
|
||||
| **HP-M6-04** | Archivage / suppression d'un produit (non prévu au docx — soft delete préparé mais non exposé, § 2.7). |
|
||||
| **HP-M6-05** | Contrainte SQL « `category` de type PRODUIT » au niveau base (au M6 : validation applicative seulement, § 2.5). |
|
||||
|
||||
## 10. Tickets Lesstime (à découper — back en tête)
|
||||
|
||||
| Ordre | Sujet | Tag |
|
||||
|---|---|---|
|
||||
| 0 | Permissions `catalog.products.view/manage` + sidebar (item sous Transporteurs) + 3 miroirs RBAC | Backend |
|
||||
| 1 | Migration : `storage_type` (+ jonction site) + `product` (+ jonctions) + seed type PRODUIT + COMMENT | Backend |
|
||||
| 2 | Entités `Product` + `StorageType` + Repositories + contrat sérialisation | Backend |
|
||||
| 3 | `ProductProvider` + `ProductProcessor` (unicité code, RG-6.03/6.05/6.06, normalisation) | Backend |
|
||||
| 4 | Référentiel `StorageType` exposé (`GetCollection` + filtre `?siteId[]`) + seed Figma + catégories PRODUIT | Backend |
|
||||
| 5 | Export XLSX | Backend |
|
||||
| 6 | Tests PHPUnit RG-6.01→6.10 + capture contrat JSON | Backend |
|
||||
| 7 | Page liste `/admin/products` (usePaginatedList) + drawer filtre + export | Frontend |
|
||||
| 8 | Écran Ajouter (champs conditionnels SALE, selects filtrés catégorie/stockage) | Frontend |
|
||||
| 9 | Écran Modification (« Enregistrer ») + onglets **placeholder « en cours de dev »** (Fournisseurs / Clients) | Frontend |
|
||||
| 10 | i18n + libellé audit (`catalog_product`) | Frontend |
|
||||
|
||||
## 📦 Tickets Lesstime générés
|
||||
|
||||
**TaskGroup Lesstime** : **#36 — M6 — Catalogue produit** (projet `ERP / Starseed`, projectId=6) — créé le 24/06/2026, 11 tickets au statut « Prêt à dev ». Back = **Matthieu**, Front = **Tristan**. Chaque ticket porte son prompt d'implémentation `.md` en pièce jointe (dossier `prompts/`).
|
||||
|
||||
| # | ERP | Ticket | Effort | Tag | Assigné |
|
||||
|---|---|---|---|---|---|
|
||||
| 1.1 | ERP-197 | Permissions catalog.products.* + sidebar + 3 miroirs RBAC | S | Backend | Matthieu |
|
||||
| 1.2 | ERP-198 | Migrer le schéma M6 (storage_type, product, jonctions, type PRODUIT) | M | Backend | Matthieu |
|
||||
| 1.3 | ERP-199 | Entités Product + StorageType + repositories + contrat sérialisation | M | Backend | Matthieu |
|
||||
| 1.4 | ERP-200 | ProductProvider + ProductProcessor (unicité code, RG-6.03/05/06) | L | Backend | Matthieu |
|
||||
| 1.5 | ERP-201 | Exposer le référentiel StorageType + seed Figma + catégories PRODUIT | M | Backend | Matthieu |
|
||||
| 1.6 | ERP-202 | Export XLSX des produits | S | Backend | Matthieu |
|
||||
| 1.7 | ERP-203 | Tests PHPUnit RG-6.01→6.10 + capture du contrat JSON | M | Backend | Matthieu |
|
||||
| 1.8 | ERP-204 | Page liste /admin/products (datatable, filtre, export) | M | Frontend | Tristan |
|
||||
| 1.9 | ERP-205 | Écran Ajouter un produit (champs conditionnels, selects filtrés) | L | Frontend | Tristan |
|
||||
| 1.10 | ERP-206 | Écran Modification + onglets placeholder (Fournisseurs/Clients) | M | Frontend | Tristan |
|
||||
| 1.11 | ERP-207 | i18n + libellé audit catalog_product | S | Frontend | Tristan |
|
||||
@@ -0,0 +1,80 @@
|
||||
# RETEX M1 (Clients) → à appliquer pour M2 (Fournisseurs)
|
||||
|
||||
> But : éviter de reproduire en M2 les erreurs de **contrat de sérialisation** qui ont bloqué M1.
|
||||
> ~80 % des frictions M1 venaient du contrat API (sérialisation / groupes / sous-ressources), **pas** du métier.
|
||||
> À lire AVANT de rédiger `spec-back.md` et `spec-front.md` du M2, et à garder ouvert pendant la rédaction.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR (les 3 erreurs à ne jamais refaire)
|
||||
|
||||
1. **Affirmer qu'un champ est « embarqué » sans vérifier les 3 maillons de sérialisation.** En M1 : `Category.code` annoncé dans `client:read`, détail annoncé embarquant contacts/adresses/ribs → **faux dans le code**. Résultat : colonnes liste vides, onglets détail impossibles à peupler.
|
||||
2. **Livrer des sous-ressources en POST-only** (pas de `GetCollection`, pas d'embed) → le front ne peut pas lister les enfants de l'agrégat.
|
||||
3. **Écrire la spec/les tickets sur une intention, pas sur le contrat réel.** Le docblock `Client` décrivait un embed jamais implémenté.
|
||||
|
||||
---
|
||||
|
||||
## 1. Contrat de sérialisation : les 3 maillons obligatoires
|
||||
|
||||
Pour **chaque champ affiché** (liste OU détail), la spec back doit prouver les trois maillons. Si un seul manque → le champ sort en quasi-IRI (`@id`/`@type` seulement) ou pas du tout.
|
||||
|
||||
| Maillon | Question | Exemple M1 raté |
|
||||
|---|---|---|
|
||||
| (a) Groupe sur la **propriété** | `#[Groups([...])]` contient-il un read-group ? | `Supplier::$addresses` sans groupe → jamais sérialisé |
|
||||
| (b) Groupe dans le **`normalizationContext` de l'opération** | l'opération (`GetCollection`/`Get`) liste-t-elle ce groupe ? | `GetCollection` en `['client:read','default:read']` |
|
||||
| (c) Read-group de l'**entité imbriquée** dans le contexte parent | pour embarquer les champs d'une relation (catégorie, site…), le contexte parent inclut-il `category:read` / `site:read` ? | `Category.code` ∈ `category:read`, absent du contexte client → pas de `code` |
|
||||
|
||||
**Règle de rédaction** : dans `spec-back.md`, faire un tableau « champ → groupe propriété → groupe(s) à ajouter au contexte de chaque opération » pour la liste ET le détail. Inclure explicitement les **relations imbriquées** (ex. catégories d'une adresse, sites d'une adresse).
|
||||
|
||||
## 2. Collections enfant d'un agrégat : décider embed vs GetCollection, et câbler en ENTIER
|
||||
|
||||
Décision à acter dès la spec back pour chaque sous-collection (contacts, adresses, RIB, lignes…) :
|
||||
|
||||
- **Embed dans le détail (recommandé pour un agrégat DDD)** : poser `#[Groups(['<root>:item:read'])]` sur la propriété + ajouter au `normalizationContext` du `Get` racine les read-groups des entités enfant **et** de leurs relations imbriquées. 1 requête, cohérent avec un composable `useX(id)`. Réservé aux ensembles **bornés** (ne viole pas la règle n°13 : elle vise les collections exposées, pas un embed borné d'item).
|
||||
- **GetCollection sous-ressource** : `/<root>/{id}/children` paginé. À réserver aux collections potentiellement volumineuses. Si choisi, **créer l'opération** (pas seulement POST).
|
||||
|
||||
❌ Anti-pattern M1 : sous-ressources avec `POST` + `Get` unitaire seulement → **aucun moyen de lister** (ids non découvrables). Interdit.
|
||||
|
||||
## 3. Vérifier le contrat sur l'API RÉELLE avant d'écrire les tickets front
|
||||
|
||||
Le blocage M1 (codes/sites/sous-collections) aurait été vu en 5 min. À mettre dans la **definition of done de la spec back** :
|
||||
|
||||
> Créer un enregistrement de test, appeler `GET /api/<resource>` (liste) ET `GET /api/<resource>/{id}` (détail), **coller la réponse JSON réelle** dans la spec. Toute donnée affichée par le front doit apparaître dans ce JSON collé.
|
||||
|
||||
## 4. La spec décrit le RÉEL, pas l'intention
|
||||
|
||||
- Bannir les « devrait être embarqué », « est exposé » non vérifiés. Décrire ce qui existe (ou ce qui sera livré dans le ticket, en le marquant clairement « à livrer »).
|
||||
- Si un docblock/commentaire existant contredit le code, le **corriger**, pas le recopier.
|
||||
|
||||
## 5. Réutiliser les acquis M1 (ne pas réinventer)
|
||||
|
||||
- **Taxonomie ERP-78** : si M2 catégorise les fournisseurs, repartir du modèle **type unique + `code` stable** (slug MAJUSCULE auto-généré, NOT NULL, figé, **lecture seule** `category:read`), filtrage métier via `?categoryCode=`. Réutiliser le contrat partagé `CategoryInterface` (pas d'import inter-module).
|
||||
- **Front** : `usePaginatedList` (listes), composants `Malio*`, `useApi()`, `formatPhoneFR`, blocs réutilisables (Contact/Adresse), pattern de blocs dynamiques + modal de confirmation.
|
||||
- **Archive** : flag `is_archived` **distinct** de `deleted_at` (soft delete). Restauration → gérer le 409 homonyme.
|
||||
- **Normalisation = serveur** (UPPERCASE nom société, Capitalize noms, lowercase email, téléphone en chiffres). Le front envoie la saisie, réaffiche la valeur normalisée renvoyée. À documenter dans la spec.
|
||||
- **Gating fin + mode strict PATCH** : PATCH par groupe de sérialisation ; tout champ hors-permission dans le payload = **403 sur l'intégralité** (pas de filtrage silencieux). Spécifier la matrice rôle × onglet.
|
||||
|
||||
## 6. Règles ABSOLUES transverses à rappeler dans la spec M2
|
||||
|
||||
- **Pagination obligatoire** (règle n°13) sur toute `GetCollection` ; échappatoire `?pagination=false` réservée aux selects de référentiels bornés.
|
||||
- **`COMMENT ON COLUMN`** (règle n°12) sur chaque colonne créée/modifiée (sinon `make test` casse). Helper standard pour les colonnes Timestampable/Blamable.
|
||||
- **Timestampable + Blamable** sur toute nouvelle entité métier (4 colonnes + trait) ; garde-fou archi.
|
||||
- **`#[Auditable]`** sur les entités métier ; **`#[AuditIgnore]`** sur les champs sensibles (équivalents BIC/IBAN/secret).
|
||||
- **`declare(strict_types=1);`** partout ; commentaires FR, code EN.
|
||||
- **Routes front à plat** (pas de préfixe module), état tableau **jamais** dans l'URL.
|
||||
- **3 miroirs RBAC** à toucher ensemble : `config/sidebar.php`, `frontend/tests/e2e/_fixtures/personas.ts`, `SeedE2ECommand.php`.
|
||||
- **Communication inter-module** uniquement via `Shared/Domain/Contract/` ou domain events — jamais d'import direct.
|
||||
|
||||
## 7. Fixtures & seed dès le départ
|
||||
|
||||
M1 a subi un aller-retour (ERP-68) faute de fixtures alignées. Pour M2 : prévoir dès la spec un seed de fournisseurs démo **couvrant tous les cas des règles métier** (relations, catégories codées, archivés, cas comptables) + comptes de rôles démo, pour vérifier le gating et le golden path sans bricolage.
|
||||
|
||||
## 8. Mini-checklist de relecture de la spec M2 (avant de la déclarer prête)
|
||||
|
||||
- [ ] Chaque champ affiché (liste + détail) a ses 3 maillons de sérialisation documentés (propriété, contexte opération, relations imbriquées).
|
||||
- [ ] Chaque sous-collection a une décision **embed vs GetCollection** explicite et **complètement câblée** (pas de POST-only).
|
||||
- [ ] Réponses JSON réelles (liste + détail) collées dans la spec back.
|
||||
- [ ] Matrice RBAC rôle × écran × onglet + mode strict PATCH spécifiés.
|
||||
- [ ] Pagination, COMMENT ON COLUMN, Timestampable/Blamable, Audit, routes à plat : rappelés.
|
||||
- [ ] Réutilisations M1 identifiées (taxonomie code, usePaginatedList, blocs, archive, normalisation).
|
||||
- [ ] Seed/fixtures démo planifiés.
|
||||
@@ -0,0 +1,353 @@
|
||||
# ERP-208 — Fix ticket de pesée — Plan d'implémentation
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development ou superpowers:executing-plans. Étapes en cases à cocher (`- [ ]`).
|
||||
|
||||
**Goal:** Ajouter le nom du tiers dans un cartouche bordé en haut à droite du bon de pesée PDF, et filtrer les listes Client/Fournisseur du formulaire de ticket sur le site courant (avec recharge au changement de site).
|
||||
|
||||
**Architecture:** Le filtre back `?siteId[]=` existe déjà sur `/clients` et `/suppliers` (joint adresses→sites) → point 2 = front uniquement. Point 1 = une méthode entité `getCounterpartyName()` + refonte du header du template Twig en table 2 colonnes (Dompdf = CSS 2.1).
|
||||
|
||||
**Tech Stack:** PHP 8.4 / Symfony / API Platform / Doctrine / Twig + Dompdf ; Nuxt 4 / Vue 3 / Vitest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `declare(strict_types=1);` en tête de tout fichier PHP.
|
||||
- Commentaires en **français**, code (noms) en anglais.
|
||||
- Front : `useApi()` uniquement, composants `Malio*`, 4 espaces, TS strict.
|
||||
- Dompdf : **CSS 2.1 uniquement** (pas de flex/grid) → mise en page par tableaux.
|
||||
- **Aucun commit sans demande explicite de Tristan** (les étapes « commit » sont différées en fin de chantier, sur demande).
|
||||
- Vérif finale : `make test` + `make nuxt-test` + `make php-cs-fixer-allow-risky`. Pas d'E2E.
|
||||
|
||||
---
|
||||
|
||||
### Task 1 : `WeighingTicket::getCounterpartyName()` (back)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Module/Logistique/Domain/Entity/WeighingTicket.php` (ajout méthode près de `getOtherLabel`, ~ligne 449)
|
||||
- Test: `tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php` (create)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `WeighingTicket::getCounterpartyName(): ?string` — companyName du client/fournisseur ou otherLabel selon `counterpartyType`, null sinon. Consommé par le template Twig (Task 2).
|
||||
|
||||
- [ ] **Step 1 : test qui échoue**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Logistique\Domain;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class WeighingTicketCounterpartyNameTest extends TestCase
|
||||
{
|
||||
public function testReturnsClientCompanyNameForClientCounterparty(): void
|
||||
{
|
||||
$client = (new Client())->setCompanyName('Ferme du Pré');
|
||||
$ticket = (new WeighingTicket())->setCounterpartyType('CLIENT')->setClient($client);
|
||||
|
||||
self::assertSame('Ferme du Pré', $ticket->getCounterpartyName());
|
||||
}
|
||||
|
||||
public function testReturnsSupplierCompanyNameForSupplierCounterparty(): void
|
||||
{
|
||||
$supplier = (new Supplier())->setCompanyName('Coop Sud');
|
||||
$ticket = (new WeighingTicket())->setCounterpartyType('FOURNISSEUR')->setSupplier($supplier);
|
||||
|
||||
self::assertSame('Coop Sud', $ticket->getCounterpartyName());
|
||||
}
|
||||
|
||||
public function testReturnsOtherLabelForOtherCounterparty(): void
|
||||
{
|
||||
$ticket = (new WeighingTicket())->setCounterpartyType('AUTRE')->setOtherLabel('Particulier');
|
||||
|
||||
self::assertSame('Particulier', $ticket->getCounterpartyName());
|
||||
}
|
||||
|
||||
public function testReturnsNullWhenNoCounterparty(): void
|
||||
{
|
||||
self::assertNull((new WeighingTicket())->getCounterpartyName());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : lancer le test → échec**
|
||||
|
||||
`make test` filtré : `docker exec php-starseed-fpm php bin/phpunit tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php`
|
||||
Attendu : FAIL (`getCounterpartyName` n'existe pas). Vérifier au passage que `Client`/`Supplier` ont bien un constructeur sans argument et `setCompanyName` (sinon adapter l'instanciation du test au pattern existant des entités).
|
||||
|
||||
- [ ] **Step 3 : implémentation minimale**
|
||||
|
||||
Dans `WeighingTicket.php`, après `getOtherLabel()`/`setOtherLabel()` :
|
||||
|
||||
```php
|
||||
/**
|
||||
* Nom du tiers à afficher (bon de pesée PDF, ERP-208) : raison sociale du
|
||||
* client/fournisseur ou libellé libre selon le type de contrepartie (RG-5.03).
|
||||
* Null si aucune contrepartie cohérente (brouillon).
|
||||
*/
|
||||
public function getCounterpartyName(): ?string
|
||||
{
|
||||
return match ($this->counterpartyType) {
|
||||
'CLIENT' => $this->client?->getCompanyName(),
|
||||
'FOURNISSEUR' => $this->supplier?->getCompanyName(),
|
||||
'AUTRE' => $this->otherLabel,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : lancer le test → succès**
|
||||
|
||||
`docker exec php-starseed-fpm php bin/phpunit tests/Module/Logistique/Domain/WeighingTicketCounterpartyNameTest.php` → PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 2 : Cartouche tiers dans le template PDF
|
||||
|
||||
**Files:**
|
||||
- Modify: `templates/logistique/weighing_ticket_print.html.twig`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ticket.counterpartyName` (Task 1).
|
||||
|
||||
- [ ] **Step 1 : ajouter le style du cartouche + header 2 colonnes**
|
||||
|
||||
Dans le `<style>`, ajouter :
|
||||
|
||||
```css
|
||||
.header { width: 100%; border-collapse: collapse; }
|
||||
.header td { vertical-align: top; }
|
||||
.header .h-right { text-align: right; }
|
||||
.party-box { display: inline-block; border: 1px solid #000; padding: 8px 12px; min-width: 160px; text-align: center; font-weight: bold; font-size: 12px; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : remplacer le bloc logo + identité par une table 2 colonnes**
|
||||
|
||||
Remplacer (logo + 3 lignes company) par :
|
||||
|
||||
```twig
|
||||
<table class="header">
|
||||
<tr>
|
||||
<td>
|
||||
{% if logoSrc %}
|
||||
<div class="logo"><img src="{{ logoSrc }}" alt="LPC LIOT"></div>
|
||||
{% endif %}
|
||||
<div class="company-name">SA LIOT Châtellerault</div>
|
||||
<div class="company-line">Email : lpc.contacts@lpc-liot.fr</div>
|
||||
<div class="company-line">RCS Châtellerault B 339 505 612</div>
|
||||
</td>
|
||||
<td class="h-right">
|
||||
{% if ticket.counterpartyName %}
|
||||
<div class="party-box">{{ ticket.counterpartyName }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
```
|
||||
|
||||
(Le `.title` « Ticket de pesée » et la suite restent inchangés, sous la table.)
|
||||
|
||||
- [ ] **Step 3 : vérifier le rendu PDF**
|
||||
|
||||
Le test existant `WeighingTicketPrintApiTest` doit rester vert :
|
||||
`docker exec php-starseed-fpm php bin/phpunit tests/Module/Logistique/Api/WeighingTicketPrintApiTest.php` → PASS (`%PDF`, content-type, disposition inchangés).
|
||||
|
||||
---
|
||||
|
||||
### Task 3 : `useWeighingTicketReferentials.load(siteId?)` (front)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/modules/logistique/composables/useWeighingTicketReferentials.ts`
|
||||
- Test: `frontend/modules/logistique/composables/__tests__/useWeighingTicketReferentials.spec.ts` (create)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `load(siteId?: number | null): Promise<void>` — passe `siteId[]=<siteId>` aux fetch `/clients` et `/suppliers` quand `siteId` est fourni ; sinon comportement actuel (liste complète).
|
||||
|
||||
- [ ] **Step 1 : test qui échoue**
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const getMock = vi.fn()
|
||||
vi.stubGlobal('useApi', () => ({ get: getMock }))
|
||||
|
||||
import { useWeighingTicketReferentials } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
||||
|
||||
describe('useWeighingTicketReferentials', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
getMock.mockResolvedValue({ member: [] })
|
||||
})
|
||||
|
||||
it('passe siteId[] aux deux endpoints quand un site est fourni', async () => {
|
||||
const { load } = useWeighingTicketReferentials()
|
||||
await load(7)
|
||||
|
||||
const clientsCall = getMock.mock.calls.find(c => c[0] === '/clients')
|
||||
const suppliersCall = getMock.mock.calls.find(c => c[0] === '/suppliers')
|
||||
expect(clientsCall?.[1]).toMatchObject({ pagination: 'false', 'siteId[]': [7] })
|
||||
expect(suppliersCall?.[1]).toMatchObject({ pagination: 'false', 'siteId[]': [7] })
|
||||
})
|
||||
|
||||
it('ne passe pas siteId[] quand aucun site (liste complète)', async () => {
|
||||
const { load } = useWeighingTicketReferentials()
|
||||
await load(null)
|
||||
|
||||
const clientsCall = getMock.mock.calls.find(c => c[0] === '/clients')
|
||||
expect(clientsCall?.[1]).not.toHaveProperty('siteId[]')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : lancer → échec**
|
||||
|
||||
`make nuxt-test` (ou ciblé) → FAIL (`load` n'accepte pas d'argument / `siteId[]` absent).
|
||||
|
||||
- [ ] **Step 3 : implémentation**
|
||||
|
||||
Modifier `fetchAll` et `load` :
|
||||
|
||||
```ts
|
||||
/** Récupère une collection complète (pagination désactivée) en Hydra, filtrée site si fourni. */
|
||||
async function fetchAll(url: string, siteId?: number | null): Promise<PartyMember[]> {
|
||||
const query: Record<string, unknown> = { pagination: 'false' }
|
||||
// Filtre par site courant (ERP-208) : un tiers est rattaché à un site via
|
||||
// les sites de ses adresses. Param `siteId[]` déjà géré par les providers M1/M2.
|
||||
if (siteId !== null && siteId !== undefined) {
|
||||
query['siteId[]'] = [siteId]
|
||||
}
|
||||
const res = await api.get<{ member?: PartyMember[] }>(
|
||||
url,
|
||||
query,
|
||||
{ headers: LD_JSON_HEADERS, toast: false },
|
||||
)
|
||||
return res.member ?? []
|
||||
}
|
||||
|
||||
async function load(siteId?: number | null): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
fetchAll('/clients', siteId).then((list) => {
|
||||
clients.value = list.map(c => ({ value: c['@id'], label: c.companyName }))
|
||||
}),
|
||||
fetchAll('/suppliers', siteId).then((list) => {
|
||||
suppliers.value = list.map(s => ({ value: s['@id'], label: s.companyName }))
|
||||
}),
|
||||
])
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : lancer → succès**
|
||||
|
||||
`make nuxt-test` ciblé sur le spec → PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 4 : Brancher site courant + recharge dans new.vue et edit.vue (front)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/modules/logistique/pages/weighing-tickets/new.vue`
|
||||
- Modify: `frontend/modules/logistique/pages/weighing-tickets/[id]/edit.vue`
|
||||
- Test: `frontend/modules/logistique/pages/__tests__/weighingTicketNew.spec.ts` (étendre)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `useCurrentSite().currentSite` (ref `Site | null`), `useWeighingTicketReferentials().load(siteId?)`, `form.clientIri` / `form.supplierIri` / `referentials.clients` / `referentials.suppliers`.
|
||||
|
||||
- [ ] **Step 1 : helper de reset partagé**
|
||||
|
||||
Logique commune aux deux pages : après recharge, vider le tiers sélectionné s'il n'est plus dans les options. Implémenté inline dans chaque page (2 lignes) — pas de nouveau composable pour si peu.
|
||||
|
||||
- [ ] **Step 2 : new.vue — brancher currentSite + watch**
|
||||
|
||||
Remplacer le bloc `onMounted` final :
|
||||
|
||||
```ts
|
||||
const { currentSite } = useCurrentSite()
|
||||
|
||||
/** Recharge les référentiels pour le site donné puis purge le tiers devenu hors-site (ERP-208). */
|
||||
async function reloadReferentials(siteId: number | null): Promise<void> {
|
||||
await referentials.load(siteId)
|
||||
if (form.clientIri.value && !referentials.clients.value.some(o => o.value === form.clientIri.value)) {
|
||||
form.clientIri.value = null
|
||||
}
|
||||
if (form.supplierIri.value && !referentials.suppliers.value.some(o => o.value === form.supplierIri.value)) {
|
||||
form.supplierIri.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
reloadReferentials(currentSite.value?.id ?? null).catch(() => {})
|
||||
})
|
||||
|
||||
// Changement de site pendant la saisie → recharge les listes du nouveau site (ERP-208).
|
||||
watch(() => currentSite.value?.id, (siteId) => {
|
||||
reloadReferentials(siteId ?? null).catch(() => {})
|
||||
})
|
||||
```
|
||||
|
||||
Ajouter `watch` à l'import `vue` et `useCurrentSite` (auto-importé Nuxt — sinon import explicite `~/modules/sites/composables/useCurrentSite`).
|
||||
|
||||
- [ ] **Step 3 : edit.vue — même branchement**
|
||||
|
||||
Adapter le `onMounted` async existant (qui fait aussi `fetchTicket`/`hydrate`) :
|
||||
|
||||
```ts
|
||||
const { currentSite } = useCurrentSite()
|
||||
|
||||
async function reloadReferentials(siteId: number | null): Promise<void> {
|
||||
await referentials.load(siteId)
|
||||
if (form.clientIri.value && !referentials.clients.value.some(o => o.value === form.clientIri.value)) {
|
||||
form.clientIri.value = null
|
||||
}
|
||||
if (form.supplierIri.value && !referentials.suppliers.value.some(o => o.value === form.supplierIri.value)) {
|
||||
form.supplierIri.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
reloadReferentials(currentSite.value?.id ?? null).catch(() => {})
|
||||
try {
|
||||
const detail = await fetchTicket(ticketId)
|
||||
ticketNumber.value = detail.number ?? ''
|
||||
form.hydrate(detail)
|
||||
}
|
||||
catch {
|
||||
error.value = true
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => currentSite.value?.id, (siteId) => {
|
||||
reloadReferentials(siteId ?? null).catch(() => {})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : étendre le spec front**
|
||||
|
||||
Dans `weighingTicketNew.spec.ts`, ajouter un cas vérifiant que `load` est appelé avec l'id du site courant au montage (mock `useCurrentSite` retournant un `currentSite` avec `id`). Adapter au style de mock déjà en place dans le fichier.
|
||||
|
||||
- [ ] **Step 5 : lancer les tests front**
|
||||
|
||||
`make nuxt-test` → PASS (specs new/edit + referentials).
|
||||
|
||||
---
|
||||
|
||||
## Vérification finale
|
||||
|
||||
- [ ] `make test` (back) — vert.
|
||||
- [ ] `make nuxt-test` (front) — vert.
|
||||
- [ ] `make php-cs-fixer-allow-risky` — pas de diff non voulu.
|
||||
- [ ] **STOP** : remettre la main à Tristan pour les tests manuels (impression PDF + switch de site). Commits différés jusqu'à sa demande.
|
||||
|
||||
## Self-review (couverture spec)
|
||||
|
||||
- Point 1 (cartouche PDF nom seul) → Task 1 + Task 2. ✓
|
||||
- Point 2 (filtre site + recharge au switch + reset-si-absent) → Task 3 + Task 4. ✓
|
||||
- Définition « lié au site » via adresses → param `siteId[]` (back déjà OK). ✓
|
||||
- Portée ticket-seulement (pas de modif répertoires) → on n'édite que le composable du ticket + ses pages. ✓
|
||||
- Pas de migration / RBAC / E2E. ✓
|
||||
@@ -0,0 +1,119 @@
|
||||
# ERP-101 — Mapping des erreurs de validation par champ (convention forms)
|
||||
|
||||
> Statut : design validé — implémentation TDD en cours
|
||||
> Branche : `feat/ERP-101-form-field-validation-mapping`
|
||||
> Date : 2026-06-03
|
||||
|
||||
## Problème
|
||||
|
||||
Quand le back renvoie une **422** (violations API Platform), il renvoie **toutes** les
|
||||
violations d'un coup (un `propertyPath` + `message` par champ fautif). Aujourd'hui, seul
|
||||
le drawer Catégorie (`useCategoryForm`) exploite ce détail pour afficher l'erreur **sous
|
||||
le champ concerné** ; il le fait via un `if/else` manuel par champ, non réutilisable.
|
||||
|
||||
Le formulaire Client (≈ 20 champs sur 5 submits, dont 3 collections) ne mappe rien : une
|
||||
422 multi-champs ⇒ un seul **toast global**. On veut un retour par champ, et surtout
|
||||
**une convention unique réutilisée par tous les modules**.
|
||||
|
||||
## Décisions
|
||||
|
||||
1. **Primitif générique** plutôt que composable par form : `useFormErrors()` partagé.
|
||||
2. **Périmètre complet** sur Client : champs scalaires **et** collections (erreur par ligne).
|
||||
|
||||
## Architecture — 3 briques
|
||||
|
||||
### 1. `mapViolationsToRecord(data)` — `frontend/shared/utils/api.ts`
|
||||
|
||||
Util pur, fondation réutilisée partout. Transforme un payload 422 en
|
||||
`Record<propertyPath, message>`. S'appuie sur `extractApiViolations` (déjà existant,
|
||||
gère les formats `violations` et `hydra:violations`).
|
||||
|
||||
```ts
|
||||
export function mapViolationsToRecord(data: unknown): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const v of extractApiViolations(data)) {
|
||||
if (v.propertyPath) out[v.propertyPath] = v.message
|
||||
}
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `useFormErrors()` — `frontend/shared/composables/useFormErrors.ts`
|
||||
|
||||
API que tous les forms **scalaires** consomment.
|
||||
|
||||
```ts
|
||||
const { errors, hasErrors, setServerErrors, setError, clearError, clearErrors, handleApiError } = useFormErrors()
|
||||
```
|
||||
|
||||
- `errors` : `reactive<Record<string, string>>` indexé par `propertyPath`.
|
||||
- `setServerErrors(data)` : `mapViolationsToRecord` → remplit `errors`. Retourne `true`
|
||||
si au moins une violation a été mappée.
|
||||
- `setError(field, msg)` / `clearError(field)` / `clearErrors()` : manipulation fine.
|
||||
- `hasErrors` : `computed` booléen.
|
||||
- `handleApiError(e, opts?)` : dispatch standard depuis une erreur ofetch —
|
||||
**422** → `setServerErrors` (mapping inline, pas de toast) ; **autre** → toast
|
||||
générique de fallback (message extrait via `extractApiErrorMessage`).
|
||||
|
||||
Côté template, le nom du champ **est** le `propertyPath` :
|
||||
|
||||
```vue
|
||||
<MalioInputText v-model="main.companyName" :error="errors.companyName" />
|
||||
<MalioInputText v-model="accounting.siren" :error="errors.siren" />
|
||||
```
|
||||
|
||||
> L'unicité SIREN (RG-1.15) remonte en **422 `UniqueEntity` avec `propertyPath: "siren"`**
|
||||
> → mappée automatiquement. Pas de cas 409 spécial (contrairement à Catégorie).
|
||||
|
||||
### 3. Collections — erreurs par ligne
|
||||
|
||||
Chaque ligne (contact / adresse / RIB) est persistée par **son propre appel API**, donc
|
||||
le back renvoie un 422 **relatif à la sous-entité** (`propertyPath: "email"`, `"iban"`…).
|
||||
|
||||
- Le parent tient, par collection, un tableau d'erreurs **aligné sur l'index de ligne** :
|
||||
`const contactErrors = ref<Record<string, string>[]>([])`.
|
||||
- Au submit de la ligne `i` : `catch` → `contactErrors.value[i] = mapViolationsToRecord(data)`.
|
||||
- On `clearErrors` la collection au début de chaque passe de submit.
|
||||
- Les blocs reçoivent une prop `:errors` (`Record<string, string>`) et bindent
|
||||
`:error="errors?.email"` sur chaque champ Malio.
|
||||
|
||||
## Fichiers touchés
|
||||
|
||||
| Fichier | Action |
|
||||
|---|---|
|
||||
| `shared/utils/api.ts` | + `mapViolationsToRecord` |
|
||||
| `shared/composables/useFormErrors.ts` | **nouveau** composable |
|
||||
| `modules/commercial/pages/clients/new.vue` | scalaires (Main/Info/Compta) + erreurs par ligne |
|
||||
| `modules/commercial/pages/clients/[id]/edit.vue` | idem |
|
||||
| `modules/commercial/components/ClientContactBlock.vue` | + prop `:errors`, bind `:error` |
|
||||
| `modules/commercial/components/ClientAddressBlock.vue` | + prop `:errors`, bind `:error` |
|
||||
| RIB (inline dans new/edit) | bind `:error` par ligne |
|
||||
|
||||
## Tests (Vitest — règle « pas d'E2E »)
|
||||
|
||||
- `mapViolationsToRecord` : formats `violations` / `hydra:violations`, payload vide,
|
||||
`propertyPath` manquant.
|
||||
- `useFormErrors` : `setServerErrors` mappe et retourne `true` / `false` sans violation,
|
||||
`clearErrors`, fallback toast sur non-422.
|
||||
|
||||
## Convention posée pour tous les forms
|
||||
|
||||
À reporter dans `.claude/rules/frontend.md` une fois le pattern stabilisé :
|
||||
|
||||
> Tout form qui veut un retour d'erreur par champ : appels API en `{ toast: false }` +
|
||||
> `useFormErrors` pour les champs scalaires (422 inline), `mapViolationsToRecord` par
|
||||
> ligne pour les collections. `useCategoryForm` migrera sur `useFormErrors`.
|
||||
|
||||
## Fait dans la foulée (post-ERP-101 initial)
|
||||
|
||||
- **`useCategoryForm` migré sur `useFormErrors`** : `errors` devient le `reactive` du
|
||||
composable (drawer adapté : `form.errors.name` au lieu de `form.errors.value.name`,
|
||||
bloc `_global` retiré → erreur transverse en toast). 28 tests verts.
|
||||
- **Convention reportée dans `.claude/rules/frontend.md`** (section « Validation des
|
||||
formulaires — useFormErrors obligatoire »).
|
||||
|
||||
## Hors scope ERP-101 (suivi : ticket ERP-107)
|
||||
|
||||
- Langue / présence des messages de validation côté back : le `message` affiché est celui
|
||||
renvoyé par le serveur. Audit des contraintes Symfony (présence d'un `message` FR,
|
||||
contraintes manquantes, violations sans `propertyPath`) tracké dans **ERP-107**.
|
||||
@@ -0,0 +1,124 @@
|
||||
# ERP-208 — Fix ticket de pesée
|
||||
|
||||
> Module : **Logistique (M5)** — écrans « Ajouter / Modifier un ticket de pesée » + bon de pesée imprimé (PDF).
|
||||
> Branche : `fix/erp-208-ticket-pesee`.
|
||||
> Date : 2026-06-25.
|
||||
|
||||
## 1. Contexte
|
||||
|
||||
Le ticket de pesée (M5) est implémenté (ERP-181 → ERP-193). Deux retours client sont
|
||||
regroupés dans ce fix :
|
||||
|
||||
1. **Bon de pesée PDF** : il manque un **cartouche bordé en haut à droite** de la
|
||||
page contenant le **nom du tiers** (client / fournisseur / champ « autre »). Le
|
||||
PDF actuel n'affiche que l'identité société (SA LIOT) en haut à gauche.
|
||||
2. **Écran de saisie** : quand l'utilisateur a **plusieurs sites autorisés**, les
|
||||
listes déroulantes Client / Fournisseur doivent être **filtrées sur le site
|
||||
courant** (le tiers est rattaché à un site via les sites de ses adresses), et
|
||||
**rechargées si l'utilisateur change de site** en restant sur la page.
|
||||
|
||||
## 2. État du code existant (constats de cadrage)
|
||||
|
||||
- **Le tiers n'a pas de site en propre.** Client (M1) et Supplier (M2) sont
|
||||
rattachés à un site **via les sites de leurs adresses** (`getSites()` agrège ;
|
||||
RG-2.06). « Lié au site » = a au moins une adresse rattachée à ce site.
|
||||
- **Le filtre back existe déjà.** `ClientProvider` / `SupplierProvider` lisent un
|
||||
filtre répétable `?siteId[]=<id>` (drawers des répertoires M1/M2) et le délèguent
|
||||
à `createListQueryBuilder(..., array $siteIds, ...)` → `applySiteIds()` qui joint
|
||||
`addresses → sites` (`site3.id IN (:siteIds)` / `site4.id IN (:siteIds)`).
|
||||
**Aucun travail back n'est nécessaire pour le filtre.**
|
||||
- **La donnée du PDF est déjà chargée.** `DoctrineWeighingTicketRepository::findById()`
|
||||
fetch-joine `client` et `supplier` ; le `WeighingTicketPrintProvider` charge le
|
||||
ticket par cette méthode. Le template a donc accès au nom du tiers.
|
||||
- **Le changement de site est global** (`SiteSelector` header → `useCurrentSite.switchSite`
|
||||
→ `PATCH /me/current-site` + `loadSidebar()` + `refreshNuxtData()`). `currentSite`
|
||||
est un ref singleton de module. Les référentiels du ticket sont chargés en
|
||||
`onMounted` **uniquement** (pas via `useAsyncData`) → ils ne se rechargent pas au
|
||||
switch : **c'est le bug du point 2.**
|
||||
- Le template PDF (`templates/logistique/weighing_ticket_print.html.twig`) est rendu
|
||||
par Dompdf → **CSS 2.1 uniquement (pas de flexbox/grid)**, mise en page par tableaux.
|
||||
|
||||
## 3. Décisions (validées avec Tristan)
|
||||
|
||||
| Sujet | Décision |
|
||||
|---|---|
|
||||
| Définition « lié au site » | Tiers ayant ≥ 1 adresse rattachée au site sélectionné (via les adresses). |
|
||||
| Portée du filtre | **Ticket de pesée seulement.** On ne modifie PAS le comportement des répertoires M1/M2 (déjà validés). On réutilise le param `?siteId[]=` existant côté front. |
|
||||
| Switch de site avec tiers sélectionné | **Reset si absent** : après rechargement, si le tiers sélectionné n'est plus dans la liste du nouveau site, on vide sa valeur (le type de contrepartie reste). S'il y est encore, on le garde. |
|
||||
| Contenu du cartouche PDF | **Nom seul** (pas de libellé « Client » / « Fournisseur » au-dessus). |
|
||||
|
||||
## 4. Conception
|
||||
|
||||
### 4.1 Point 1 — Cartouche tiers sur le bon de pesée (back + template)
|
||||
|
||||
**a. Résolution du nom — `WeighingTicket::getCounterpartyName(): ?string`**
|
||||
|
||||
Nouvelle méthode sur l'entité qui retourne, selon `counterpartyType` :
|
||||
- `CLIENT` → `client?->getCompanyName()`
|
||||
- `FOURNISSEUR` → `supplier?->getCompanyName()`
|
||||
- `AUTRE` → `otherLabel`
|
||||
- défaut → `null`
|
||||
|
||||
Rationale : garde le Twig « bête » (un seul `{{ ticket.counterpartyName }}`) et rend
|
||||
la logique testable unitairement, sans toucher le provider ni le renderer.
|
||||
|
||||
**b. Template `weighing_ticket_print.html.twig`**
|
||||
|
||||
Passer le bloc d'en-tête en **table 2 colonnes** (contrainte Dompdf CSS 2.1) :
|
||||
- colonne gauche (`width:auto`, `vertical-align:top`) : logo + identité société
|
||||
(contenu **inchangé**) ;
|
||||
- colonne droite (`text-align:right`, `vertical-align:top`) : un cartouche
|
||||
`border:1px solid #000; padding:8px;` (largeur fixe, ~200px) contenant
|
||||
`{{ ticket.counterpartyName }}` (nom seul, en gras).
|
||||
|
||||
Le reste du template (titre, table des pesées, poids net) est inchangé.
|
||||
|
||||
Cas `counterpartyName` null : en pratique l'impression a lieu après validation, où la
|
||||
contrepartie est requise (groupe `finalize`). Par robustesse, si null → ne pas rendre
|
||||
le cartouche (pas de cadre vide).
|
||||
|
||||
**c. Provider / renderer** : aucun changement (relations déjà fetch-jointes).
|
||||
|
||||
### 4.2 Point 2 — Listes filtrées par site + recharge au switch (front uniquement)
|
||||
|
||||
**a. `useWeighingTicketReferentials.ts`**
|
||||
|
||||
`load()` accepte un identifiant de site optionnel et l'injecte comme `siteId[]` dans
|
||||
les requêtes `/clients` et `/suppliers` (en plus de `pagination=false`) :
|
||||
- site fourni → `{ pagination: 'false', 'siteId[]': [siteId] }` ;
|
||||
- site absent (`null`) → comportement actuel (liste complète, dégradé gracieux).
|
||||
|
||||
**b. Pages `weighing-tickets/new.vue` et `weighing-tickets/[id]/edit.vue`**
|
||||
|
||||
- récupèrent `currentSite` via `useCurrentSite()` ;
|
||||
- `onMounted` → `referentials.load(currentSite.value?.id ?? null)` ;
|
||||
- `watch(currentSite)` (sur l'id) → `referentials.load(newId)` puis **reset-si-absent** :
|
||||
- si `form.clientIri` est défini et absent de `referentials.clients` → `form.clientIri = null` ;
|
||||
- si `form.supplierIri` est défini et absent de `referentials.suppliers` → `form.supplierIri = null` ;
|
||||
- `counterpartyType` et `otherLabel` ne sont pas touchés.
|
||||
|
||||
Note : le reset s'appuie sur les options (IRI `@id`) renvoyées par le référentiel ;
|
||||
la comparaison se fait sur `value` (l'IRI Hydra).
|
||||
|
||||
**c. Cohérence avec la liste des tickets** : la liste `/weighing_tickets` est déjà
|
||||
cloisonnée par site (provider M5). Filtrer les selects sur le site courant aligne la
|
||||
saisie sur la liste.
|
||||
|
||||
## 5. Tests & vérification
|
||||
|
||||
| Niveau | Test | Contenu |
|
||||
|---|---|---|
|
||||
| Back (PHPUnit) | unitaire `WeighingTicket::getCounterpartyName()` | 3 cas : CLIENT → companyName, FOURNISSEUR → companyName, AUTRE → otherLabel ; + null si type absent. |
|
||||
| Back | `WeighingTicketPrintApiTest` (existant) | reste vert (`%PDF`, content-type, disposition). |
|
||||
| Front (Vitest) | `weighingTicketNew.spec.ts` / `weighingTicketEdit.spec.ts` | `load` passe `siteId[]` quand un site courant existe ; au changement de `currentSite` → rechargement + reset-si-absent du tiers sélectionné. |
|
||||
|
||||
Commandes : `make test` + `make nuxt-test` + `make php-cs-fixer-allow-risky`.
|
||||
Pas de test E2E (règle d'or : Vitest privilégié).
|
||||
|
||||
## 6. Hors périmètre / non-objectifs
|
||||
|
||||
- Pas de modification du comportement des répertoires Clients / Fournisseurs (M1/M2).
|
||||
- Pas de nouvelle permission RBAC, pas de migration, pas de changement de schéma.
|
||||
- Pas de cloisonnement par site « global » sur `/clients` et `/suppliers` (rejeté :
|
||||
on garde le filtre opt-in via `?siteId[]`).
|
||||
- L'identité société du PDF reste fixe (décision ERP-192, ne change pas selon le site).
|
||||
@@ -2,7 +2,9 @@
|
||||
Valeurs en dur issues de la maquette Figma (design Starseed) :
|
||||
- sidebar depliee : 232px (w-[232px], repli laisse par defaut 72px)
|
||||
- marge horizontale du contenu sur desktop : 170px (xl:px-[170px])
|
||||
- bande blanche sticky sous la navbar : 47px (h-[47px])
|
||||
La marge haute du contenu (44px) vit desormais DANS l'entete (PageHeader,
|
||||
pt-11) et non sur le <main> : sinon, l'entete etant sticky, ce padding
|
||||
laissait un trou blanc entre le SiteSelector et l'entete.
|
||||
A faire evoluer uniquement avec une mise a jour de maquette.
|
||||
-->
|
||||
<template>
|
||||
@@ -25,9 +27,6 @@
|
||||
<SiteSelector v-if="showSiteSelector"/>
|
||||
<main
|
||||
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-11">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none sticky top-0 z-30 h-11 flex-shrink-0 bg-white"/>
|
||||
<slot/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+674
-30
@@ -2,6 +2,7 @@
|
||||
"common": {
|
||||
"loading": "Chargement...",
|
||||
"save": "Enregistrer",
|
||||
"validate": "Valider",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"edit": "Modifier",
|
||||
@@ -10,7 +11,11 @@
|
||||
"confirm": "Confirmer",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"actions": "Actions"
|
||||
"actions": "Actions",
|
||||
"comingSoon": {
|
||||
"title": "En cours de dev",
|
||||
"subtitle": "Cette fonctionnalité arrive bientôt."
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"administration": {
|
||||
@@ -26,6 +31,18 @@
|
||||
"clients": "Répertoire clients",
|
||||
"suppliers": "Répertoire fournisseurs"
|
||||
},
|
||||
"technique": {
|
||||
"section": "Technique",
|
||||
"providers": "Répertoire prestataires"
|
||||
},
|
||||
"transport": {
|
||||
"section": "Transport",
|
||||
"carriers": "Répertoire transporteurs"
|
||||
},
|
||||
"logistique": {
|
||||
"section": "Logistique",
|
||||
"weighing_tickets": "Tickets de pesée"
|
||||
},
|
||||
"core": {
|
||||
"roles": "Gestion des rôles",
|
||||
"users": "Utilisateurs",
|
||||
@@ -35,7 +52,8 @@
|
||||
"admin": "Sites"
|
||||
},
|
||||
"catalog": {
|
||||
"categories": "Gestion des catégories"
|
||||
"categories": "Gestion des catégories",
|
||||
"products": "Produits"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -45,6 +63,152 @@
|
||||
"commercial": {
|
||||
"title": "Commercial",
|
||||
"welcome": "Module Commercial",
|
||||
"suppliers": {
|
||||
"title": "Répertoire fournisseurs",
|
||||
"add": "Ajouter",
|
||||
"export": "Exporter",
|
||||
"empty": "Aucun fournisseur pour l'instant.",
|
||||
"column": {
|
||||
"companyName": "Nom",
|
||||
"categories": "Catégories",
|
||||
"sites": "Site",
|
||||
"lastActivity": "Dernière modification"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"search": "Recherche",
|
||||
"categories": "Catégories",
|
||||
"sites": "Sites",
|
||||
"status": "Statut",
|
||||
"archivedOnly": "Voir les archivés",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
"toast": {
|
||||
"error": "Une erreur est survenue. Réessayez.",
|
||||
"exportError": "L'export du répertoire fournisseurs a échoué. Réessayez.",
|
||||
"createSuccess": "Fournisseur créé avec succès",
|
||||
"updateSuccess": "Fournisseur mis à jour avec succès",
|
||||
"addComplete": "Fournisseur ajouté",
|
||||
"archiveSuccess": "Fournisseur archivé avec succès",
|
||||
"restoreSuccess": "Fournisseur restauré avec succès",
|
||||
"restoreConflict": "Impossible de restaurer : un fournisseur actif portant ce nom existe déjà."
|
||||
},
|
||||
"comingSoon": "À venir",
|
||||
"tab": {
|
||||
"information": "Information",
|
||||
"contacts": "Contacts",
|
||||
"addresses": "Adresses",
|
||||
"transport": "Transport",
|
||||
"accounting": "Comptabilité",
|
||||
"statistics": "Statistiques",
|
||||
"reports": "Rapports",
|
||||
"exchanges": "Échanges"
|
||||
},
|
||||
"action": {
|
||||
"edit": "Modifier",
|
||||
"archive": "Archiver",
|
||||
"restore": "Restaurer"
|
||||
},
|
||||
"consultation": {
|
||||
"title": "Consultation fournisseur",
|
||||
"back": "Retour au répertoire",
|
||||
"loading": "Chargement du fournisseur…",
|
||||
"notFound": "Fournisseur introuvable.",
|
||||
"confirmArchive": {
|
||||
"title": "Archiver le fournisseur",
|
||||
"message": "Ce fournisseur n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
|
||||
},
|
||||
"confirmRestore": {
|
||||
"title": "Restaurer le fournisseur",
|
||||
"message": "Ce fournisseur réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le fournisseur",
|
||||
"back": "Retour à la consultation",
|
||||
"loading": "Chargement du fournisseur…",
|
||||
"notFound": "Fournisseur introuvable.",
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"form": {
|
||||
"title": "Ajouter un fournisseur",
|
||||
"back": "Précédent",
|
||||
"submit": "Valider",
|
||||
"duplicateCompany": "Un fournisseur portant ce nom de société existe déjà.",
|
||||
"main": {
|
||||
"companyName": "Nom du fournisseur (Entreprise)",
|
||||
"categories": "Catégorie"
|
||||
},
|
||||
"information": {
|
||||
"description": "Description",
|
||||
"competitors": "Concurrent",
|
||||
"foundedAt": "Date de création",
|
||||
"employeesCount": "Nombre de salariés",
|
||||
"revenueAmount": "CA",
|
||||
"profitAmount": "Résultat",
|
||||
"directorName": "Dirigeant",
|
||||
"volumeForecast": "Volume prévisionnel"
|
||||
},
|
||||
"contact": {
|
||||
"title": "Contact {n}",
|
||||
"lastName": "Nom",
|
||||
"firstName": "Prénom",
|
||||
"jobTitle": "Fonction",
|
||||
"email": "Email",
|
||||
"phonePrimary": "Téléphone",
|
||||
"phoneSecondary": "Téléphone (2)",
|
||||
"addPhone": "Ajouter un numéro",
|
||||
"remove": "Supprimer le contact",
|
||||
"add": "Nouveau contact"
|
||||
},
|
||||
"address": {
|
||||
"title": "Adresse {n}",
|
||||
"addressType": "Type d'adresse",
|
||||
"addressTypeProspect": "Prospect",
|
||||
"addressTypeDepart": "Départ",
|
||||
"addressTypeRendu": "Rendu",
|
||||
"categories": "Catégorie",
|
||||
"country": "Pays",
|
||||
"postalCode": "Code postal",
|
||||
"city": "Ville",
|
||||
"street": "Adresse",
|
||||
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
|
||||
"streetComplement": "Adresse complémentaire",
|
||||
"sites": "Sites",
|
||||
"contacts": "Contact(s) rattaché(s)",
|
||||
"bennes": "Benne(s)",
|
||||
"triageProvider": "Prestation de triage",
|
||||
"remove": "Supprimer l'adresse",
|
||||
"add": "Nouvelle adresse",
|
||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||
},
|
||||
"accounting": {
|
||||
"infoTitle": "Informations",
|
||||
"siren": "SIREN",
|
||||
"accountNumber": "Numéro de compte",
|
||||
"tvaMode": "Mode de TVA",
|
||||
"nTva": "N° de TVA",
|
||||
"paymentDelay": "Délai de règlement",
|
||||
"paymentType": "Type de règlement",
|
||||
"bank": "Banque",
|
||||
"ribTitle": "RIB {n}",
|
||||
"ribLabel": "Libellé",
|
||||
"ribBic": "BIC",
|
||||
"ribIban": "IBAN",
|
||||
"addRib": "Ajouter un RIB",
|
||||
"removeRib": "Supprimer le RIB"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Confirmer la suppression",
|
||||
"contact": "Supprimer ce contact ?",
|
||||
"address": "Supprimer cette adresse ?",
|
||||
"rib": "Supprimer ce RIB ?",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Confirmer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"clients": {
|
||||
"title": "Répertoire clients",
|
||||
"add": "Ajouter",
|
||||
@@ -54,7 +218,7 @@
|
||||
"companyName": "Nom",
|
||||
"categories": "Catégories",
|
||||
"sites": "Site",
|
||||
"lastActivity": "Dernière activité"
|
||||
"lastActivity": "Dernière modification"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
@@ -84,6 +248,7 @@
|
||||
"toast": {
|
||||
"createSuccess": "Client créé avec succès",
|
||||
"updateSuccess": "Client mis à jour avec succès",
|
||||
"addComplete": "Client ajouté",
|
||||
"archiveSuccess": "Client archivé avec succès",
|
||||
"restoreSuccess": "Client restauré avec succès",
|
||||
"error": "Une erreur est survenue. Réessayez.",
|
||||
@@ -95,8 +260,6 @@
|
||||
"back": "Retour au répertoire",
|
||||
"loading": "Chargement du client…",
|
||||
"notFound": "Client introuvable.",
|
||||
"emptyContacts": "Aucun contact enregistré.",
|
||||
"emptyAddresses": "Aucune adresse enregistrée.",
|
||||
"confirmArchive": {
|
||||
"title": "Archiver le client",
|
||||
"message": "Ce client n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
|
||||
@@ -108,12 +271,10 @@
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le client",
|
||||
"back": "Retour au répertoire",
|
||||
"back": "Retour à la consultation",
|
||||
"loading": "Chargement du client…",
|
||||
"notFound": "Client introuvable.",
|
||||
"emptyContacts": "Aucun contact enregistré.",
|
||||
"emptyAddresses": "Aucune adresse enregistrée.",
|
||||
"save": "Valider"
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"validation": {
|
||||
"informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.",
|
||||
@@ -133,14 +294,9 @@
|
||||
"duplicateCompany": "Un client portant ce nom de société existe déjà.",
|
||||
"main": {
|
||||
"companyName": "Nom du client (Entreprise)",
|
||||
"firstName": "Prénom du contact principal",
|
||||
"lastName": "Nom du contact principal",
|
||||
"email": "Email",
|
||||
"phonePrimary": "Téléphone",
|
||||
"phoneSecondary": "Téléphone (2)",
|
||||
"addPhone": "Ajouter un numéro",
|
||||
"categories": "Catégorie",
|
||||
"relation": "Distributeur / Courtier",
|
||||
"relationNone": "Aucun",
|
||||
"relationDistributor": "Dépend du distributeur",
|
||||
"relationBroker": "Dépend du courtier",
|
||||
"distributorName": "Nom du distributeur",
|
||||
@@ -173,20 +329,31 @@
|
||||
"prospect": "Prospect",
|
||||
"delivery": "Adresse de livraison",
|
||||
"billing": "Facturation",
|
||||
"addressType": "Type d'adresse",
|
||||
"addressTypeProspect": "Prospect",
|
||||
"addressTypeDelivery": "Livraison",
|
||||
"addressTypeBilling": "Facturation",
|
||||
"addressTypeDeliveryBilling": "Adresse + Facturation",
|
||||
"addressTypeBroker": "Adresse Courtier",
|
||||
"addressTypeDistributor": "Adresse Distributeur",
|
||||
"categories": "Catégorie",
|
||||
"country": "Pays",
|
||||
"postalCode": "Code postal",
|
||||
"city": "Ville",
|
||||
"street": "Adresse",
|
||||
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
|
||||
"streetComplement": "Adresse complémentaire",
|
||||
"sites": "Sites Starseed",
|
||||
"sites": "Sites",
|
||||
"contacts": "Contact(s) rattaché(s)",
|
||||
"billingEmail": "Email de facturation",
|
||||
"billingEmailSecondary": "Email de facturation secondaire",
|
||||
"addBillingEmail": "Ajouter un email",
|
||||
"remove": "Supprimer l'adresse",
|
||||
"add": "Nouvelle adresse",
|
||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||
},
|
||||
"accounting": {
|
||||
"infoTitle": "Informations",
|
||||
"siren": "SIREN",
|
||||
"accountNumber": "Numéro de compte",
|
||||
"tvaMode": "Mode de TVA",
|
||||
@@ -212,6 +379,397 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"technique": {
|
||||
"providers": {
|
||||
"title": "Répertoire prestataires",
|
||||
"add": "Ajouter",
|
||||
"export": "Exporter",
|
||||
"empty": "Aucun prestataire pour l'instant.",
|
||||
"column": {
|
||||
"companyName": "Nom",
|
||||
"categories": "Catégories",
|
||||
"sites": "Site",
|
||||
"lastActivity": "Dernière modification"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"search": "Recherche",
|
||||
"categories": "Catégories",
|
||||
"sites": "Sites",
|
||||
"status": "Statut",
|
||||
"archivedOnly": "Voir les archivés",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
"tab": {
|
||||
"contact": "Contact",
|
||||
"contacts": "Contacts",
|
||||
"address": "Adresse",
|
||||
"reports": "Rapports",
|
||||
"exchanges": "Échanges",
|
||||
"accounting": "Comptabilité"
|
||||
},
|
||||
"action": {
|
||||
"edit": "Modifier",
|
||||
"archive": "Archiver",
|
||||
"restore": "Restaurer"
|
||||
},
|
||||
"consultation": {
|
||||
"title": "Fiche prestataire",
|
||||
"back": "Retour au répertoire",
|
||||
"loading": "Chargement…",
|
||||
"notFound": "Prestataire introuvable.",
|
||||
"confirmArchive": "Archiver ce prestataire ? Il n'apparaîtra plus dans le répertoire actif.",
|
||||
"confirmRestore": "Restaurer ce prestataire ? Il réapparaîtra dans le répertoire actif."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le prestataire",
|
||||
"back": "Retour à la consultation",
|
||||
"loading": "Chargement…",
|
||||
"notFound": "Prestataire introuvable.",
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"form": {
|
||||
"title": "Ajouter un prestataire",
|
||||
"back": "Précédent",
|
||||
"submit": "Valider",
|
||||
"duplicateCompany": "Un prestataire portant ce nom de société existe déjà.",
|
||||
"main": {
|
||||
"companyName": "Nom du prestataire (Entreprise)",
|
||||
"categories": "Catégorie",
|
||||
"sites": "Site"
|
||||
},
|
||||
"errors": {
|
||||
"nameRequired": "Le nom du prestataire est obligatoire.",
|
||||
"siteRequired": "Sélectionnez au moins un site.",
|
||||
"categoryRequired": "Sélectionnez au moins une catégorie."
|
||||
},
|
||||
"contact": {
|
||||
"title": "Contact {n}",
|
||||
"lastName": "Nom",
|
||||
"firstName": "Prénom",
|
||||
"jobTitle": "Fonction",
|
||||
"email": "Email",
|
||||
"phonePrimary": "Téléphone",
|
||||
"phoneSecondary": "Téléphone (2)",
|
||||
"addPhone": "Ajouter un numéro",
|
||||
"remove": "Supprimer le contact",
|
||||
"add": "Nouveau contact"
|
||||
},
|
||||
"address": {
|
||||
"title": "Adresse {n}",
|
||||
"sites": "Sites",
|
||||
"contacts": "Contact(s) rattaché(s)",
|
||||
"country": "Pays",
|
||||
"postalCode": "Code postal",
|
||||
"city": "Ville",
|
||||
"street": "Adresse",
|
||||
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
|
||||
"streetComplement": "Adresse complémentaire",
|
||||
"remove": "Supprimer l'adresse",
|
||||
"add": "Nouvelle adresse",
|
||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||
},
|
||||
"accounting": {
|
||||
"infoTitle": "Informations",
|
||||
"siren": "SIREN",
|
||||
"accountNumber": "Numéro de compte",
|
||||
"tvaMode": "Mode de TVA",
|
||||
"nTva": "N° de TVA",
|
||||
"paymentDelay": "Délai de règlement",
|
||||
"paymentType": "Type de règlement",
|
||||
"bank": "Banque",
|
||||
"ribTitle": "RIB {n}",
|
||||
"ribLabel": "Libellé",
|
||||
"ribBic": "BIC",
|
||||
"ribIban": "IBAN",
|
||||
"addRib": "Ajouter un RIB",
|
||||
"removeRib": "Supprimer le RIB"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Confirmer la suppression",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Supprimer",
|
||||
"contact": "Supprimer ce contact ?",
|
||||
"address": "Supprimer cette adresse ?",
|
||||
"rib": "Supprimer ce RIB ?"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"error": "Une erreur est survenue. Réessayez.",
|
||||
"exportError": "L'export du répertoire prestataires a échoué. Réessayez.",
|
||||
"createSuccess": "Prestataire créé avec succès",
|
||||
"updateSuccess": "Prestataire mis à jour avec succès",
|
||||
"addComplete": "Prestataire ajouté",
|
||||
"archiveSuccess": "Prestataire archivé avec succès",
|
||||
"restoreSuccess": "Prestataire restauré avec succès"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transport": {
|
||||
"carriers": {
|
||||
"title": "Répertoire transporteurs",
|
||||
"add": "Ajouter",
|
||||
"export": "Exporter",
|
||||
"empty": "Aucun transporteur pour l'instant.",
|
||||
"column": {
|
||||
"name": "Nom",
|
||||
"certification": "Certification",
|
||||
"validityDate": "Date de validité",
|
||||
"lastActivity": "Dernière modification"
|
||||
},
|
||||
"certification": {
|
||||
"QUALIMAT": "QUALIMAT",
|
||||
"GMP_PLUS": "GMP+",
|
||||
"OVOCOM": "OVOCOM",
|
||||
"COMPTE_PROPRE": "Compte-propre",
|
||||
"AUTRE": "Autre"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"search": "Recherche",
|
||||
"certification": "Certification",
|
||||
"status": "Statut",
|
||||
"archivedOnly": "Voir les archivés",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
"toast": {
|
||||
"error": "Une erreur est survenue. Réessayez.",
|
||||
"exportError": "L'export du répertoire transporteurs a échoué. Réessayez.",
|
||||
"createSuccess": "Transporteur créé avec succès",
|
||||
"integrateSuccess": "Transporteur QUALIMAT intégré",
|
||||
"addressSaved": "Adresse enregistrée",
|
||||
"contactSaved": "Contact enregistré",
|
||||
"priceSaved": "Prix enregistré",
|
||||
"updateSuccess": "Transporteur mis à jour avec succès",
|
||||
"archiveSuccess": "Transporteur archivé avec succès",
|
||||
"restoreSuccess": "Transporteur restauré avec succès"
|
||||
},
|
||||
"action": {
|
||||
"edit": "Modifier",
|
||||
"archive": "Archiver",
|
||||
"restore": "Restaurer"
|
||||
},
|
||||
"consultation": {
|
||||
"title": "Consultation transporteur",
|
||||
"back": "Retour au répertoire",
|
||||
"loading": "Chargement du transporteur…",
|
||||
"notFound": "Transporteur introuvable.",
|
||||
"confirmArchive": {
|
||||
"title": "Archiver le transporteur",
|
||||
"message": "Ce transporteur n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
|
||||
},
|
||||
"confirmRestore": {
|
||||
"title": "Restaurer le transporteur",
|
||||
"message": "Ce transporteur réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
|
||||
},
|
||||
"price": {
|
||||
"group": "Transport",
|
||||
"carrier": "Fournisseurs / Clients",
|
||||
"aproOrSite": "Adresse sites",
|
||||
"delivery": "Adresse livraisons",
|
||||
"forfait": "Forfait (€)",
|
||||
"tonne": "Tonne (€)",
|
||||
"indexation": "Indexation",
|
||||
"state": "État du prix",
|
||||
"export": "Exporter",
|
||||
"empty": "Aucun prix pour ce transporteur."
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le transporteur",
|
||||
"back": "Retour à la consultation",
|
||||
"loading": "Chargement du transporteur…",
|
||||
"notFound": "Transporteur introuvable.",
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"containerType": {
|
||||
"BENNE": "Benne",
|
||||
"FOND_MOUVANT": "Fond mouvant"
|
||||
},
|
||||
"tab": {
|
||||
"qualimat": "Qualimat",
|
||||
"addresses": "Adresses",
|
||||
"contacts": "Contacts",
|
||||
"prices": "Prix"
|
||||
},
|
||||
"form": {
|
||||
"title": "Ajouter un transporteur",
|
||||
"back": "Retour au répertoire",
|
||||
"submit": "Valider",
|
||||
"comingSoon": "À venir",
|
||||
"duplicateName": "Un transporteur actif portant ce nom existe déjà.",
|
||||
"main": {
|
||||
"name": "Nom",
|
||||
"certificationType": "Certification transport",
|
||||
"isChartered": "Affréter",
|
||||
"indexationRate": "Indexation %",
|
||||
"containerType": "Benne / Fond mouvant",
|
||||
"volumeM3": "Volume m³",
|
||||
"discharge": "Décharge",
|
||||
"liotPlates": "Immatriculations LIOT",
|
||||
"liotPlatesHint": "Séparées par « ; »"
|
||||
},
|
||||
"qualimat": {
|
||||
"empty": "Aucun transporteur QUALIMAT trouvé.",
|
||||
"searchHint": "Saisissez le nom du transporteur pour lancer la recherche.",
|
||||
"columns": {
|
||||
"name": "Nom",
|
||||
"address": "Adresse",
|
||||
"validityDate": "Date de validité"
|
||||
},
|
||||
"confirm": {
|
||||
"title": "Intégration QUALIMAT",
|
||||
"message": "Êtes-vous sûr de vouloir intégrer ce transporteur ?",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Intégrer"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"nameRequired": "Le nom du transporteur est obligatoire.",
|
||||
"certificationRequired": "Le type de certification est obligatoire.",
|
||||
"dischargeRequired": "La décharge est obligatoire pour une certification « Autre ».",
|
||||
"indexationRequired": "Le taux d'indexation est obligatoire pour un transporteur affrété.",
|
||||
"containerTypeRequired": "Le type de contenant est obligatoire pour un transporteur affrété.",
|
||||
"volumeRequired": "Le volume est obligatoire pour un transporteur affrété.",
|
||||
"uploadFailed": "Le téléversement de la décharge a échoué."
|
||||
},
|
||||
"address": {
|
||||
"title": "Adresse",
|
||||
"country": "Pays",
|
||||
"postalCode": "Code postal",
|
||||
"city": "Ville",
|
||||
"street": "Adresse",
|
||||
"streetComplement": "Adresse complémentaire",
|
||||
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
|
||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||
},
|
||||
"contact": {
|
||||
"title": "Contact {n}",
|
||||
"lastName": "Nom",
|
||||
"firstName": "Prénom",
|
||||
"jobTitle": "Fonction",
|
||||
"phonePrimary": "Téléphone",
|
||||
"phoneSecondary": "Téléphone (2)",
|
||||
"addPhone": "Ajouter un numéro",
|
||||
"email": "Email",
|
||||
"add": "Nouveau contact",
|
||||
"remove": "Supprimer le contact"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Supprimer ce bloc",
|
||||
"message": "Cette suppression est définitive. Confirmer ?",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Supprimer"
|
||||
},
|
||||
"price": {
|
||||
"title": "Prix {n}",
|
||||
"direction": "Sens",
|
||||
"directionClient": "Client",
|
||||
"directionSupplier": "Fournisseur",
|
||||
"client": "Client",
|
||||
"clientDeliveryAddress": "Adresse de livraison",
|
||||
"departureSite": "Adresse de départ",
|
||||
"supplier": "Fournisseur",
|
||||
"supplierSupplyAddress": "Adresse d'approvisionnement",
|
||||
"deliverySite": "Adresse de livraison",
|
||||
"containerType": "Benne / Fond mouvant",
|
||||
"pricingUnit": "Forfait / Tonne",
|
||||
"pricingForfait": "Forfait",
|
||||
"pricingTonne": "Tonne",
|
||||
"price": "Prix",
|
||||
"priceState": "État du prix",
|
||||
"stateEnCours": "En cours",
|
||||
"stateValide": "Validé",
|
||||
"stateNonValide": "Non validé",
|
||||
"add": "Nouveau prix",
|
||||
"remove": "Supprimer le prix",
|
||||
"errors": {
|
||||
"direction": "Le sens du prix est obligatoire.",
|
||||
"client": "Le client est obligatoire pour un prix client.",
|
||||
"clientDeliveryAddress": "L'adresse de livraison du client est obligatoire pour un prix client.",
|
||||
"departureSite": "Le site de départ est obligatoire pour un prix client.",
|
||||
"supplier": "Le fournisseur est obligatoire pour un prix fournisseur.",
|
||||
"supplierSupplyAddress": "L'adresse d'approvisionnement est obligatoire pour un prix fournisseur.",
|
||||
"deliverySite": "Le site de livraison est obligatoire pour un prix fournisseur.",
|
||||
"containerType": "Le type de contenant est obligatoire.",
|
||||
"pricingUnit": "L'unité de tarification est obligatoire.",
|
||||
"price": "Le prix est obligatoire.",
|
||||
"priceState": "L'état du prix est obligatoire."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"status": "Statut"
|
||||
},
|
||||
"status": {
|
||||
"draft": "En attente",
|
||||
"validated": "Terminée"
|
||||
},
|
||||
"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",
|
||||
"print": "Imprimer",
|
||||
"weightRequired": "Le poids est obligatoire : effectuez une pesée.",
|
||||
"dsdRequired": "Le DSD est obligatoire : effectuez une pesée.",
|
||||
"counterparty": {
|
||||
"type": "Fournisseur / Client / Autre",
|
||||
"supplier": "Fournisseur",
|
||||
"client": "Client",
|
||||
"other": "Autre"
|
||||
},
|
||||
"weighbridge": {
|
||||
"auto": "Pesée bascule",
|
||||
"manual": "Pesée manuelle",
|
||||
"confirmTitle": "Êtes-vous sûr de vouloir déclencher une pesée ?",
|
||||
"validate": "Valider",
|
||||
"unavailable": "Pont bascule indisponible — passez en pesée manuelle."
|
||||
},
|
||||
"manual": {
|
||||
"title": "Pesée manuelle",
|
||||
"weight": "Poids (Kg)",
|
||||
"dsd": "DSD",
|
||||
"save": "Enregistrer",
|
||||
"weightRequired": "Le poids est obligatoire.",
|
||||
"dsdRequired": "Le DSD est obligatoire."
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"title": "Ticket de pesée {number}",
|
||||
"titleFallback": "Modifier un ticket de pesée",
|
||||
"loading": "Chargement du ticket…",
|
||||
"notFound": "Ticket de pesée introuvable."
|
||||
},
|
||||
"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",
|
||||
@@ -233,6 +791,12 @@
|
||||
},
|
||||
"sites": {
|
||||
"notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site."
|
||||
},
|
||||
"title": "Erreur",
|
||||
"generic": "Une erreur est survenue.",
|
||||
"unknown": "Erreur inconnue.",
|
||||
"validation": {
|
||||
"invalidDate": "Date invalide"
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
@@ -248,15 +812,29 @@
|
||||
"delete": "Suppression"
|
||||
},
|
||||
"entity": {
|
||||
"core_user": "Utilisateur",
|
||||
"core_role": "Rôle",
|
||||
"core_permission": "Permission",
|
||||
"sites_site": "Site",
|
||||
"catalog_category": "Catégorie",
|
||||
"commercial_client": "Client",
|
||||
"core_user": "Utilisateur",
|
||||
"core_role": "Rôle",
|
||||
"core_permission": "Permission",
|
||||
"sites_site": "Site",
|
||||
"catalog_category": "Catégorie",
|
||||
"catalog_product": "Produit",
|
||||
"commercial_client": "Client",
|
||||
"commercial_clientaddress": "Adresse client",
|
||||
"commercial_clientcontact": "Contact client",
|
||||
"commercial_clientrib": "RIB client"
|
||||
"commercial_clientrib": "RIB client",
|
||||
"commercial_supplier": "Fournisseur",
|
||||
"commercial_supplieraddress": "Adresse fournisseur",
|
||||
"commercial_suppliercontact": "Contact fournisseur",
|
||||
"commercial_supplierrib": "RIB fournisseur",
|
||||
"technique_provider": "Prestataire",
|
||||
"technique_provideraddress": "Adresse prestataire",
|
||||
"technique_providercontact": "Contact prestataire",
|
||||
"technique_providerrib": "RIB prestataire",
|
||||
"transport_carrier": "Transporteur",
|
||||
"transport_carrieraddress": "Adresse transporteur",
|
||||
"transport_carriercontact": "Contact transporteur",
|
||||
"transport_carrierprice": "Prix transporteur",
|
||||
"logistique_weighingticket": "Ticket de pesée"
|
||||
},
|
||||
"empty": "Aucune activité enregistrée",
|
||||
"no_results": "Aucun résultat pour ces filtres",
|
||||
@@ -290,7 +868,9 @@
|
||||
"success": {
|
||||
"auth": {
|
||||
"logout": "Deconnexion reussie"
|
||||
}
|
||||
},
|
||||
"title": "Succès",
|
||||
"deleted": "Suppression effectuée"
|
||||
},
|
||||
"admin": {
|
||||
"roles": {
|
||||
@@ -408,21 +988,26 @@
|
||||
"newCategory": "Ajouter",
|
||||
"editCategory": "Modifier la catégorie",
|
||||
"createCategory": "Créer une catégorie",
|
||||
"viewCategory": "Détail de la catégorie",
|
||||
"noCategories": "Aucune catégorie pour l'instant.",
|
||||
"table": {
|
||||
"name": "Nom",
|
||||
"type": "Type"
|
||||
"types": "Types"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"search": "Recherche",
|
||||
"types": "Types de catégorie",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
"form": {
|
||||
"name": "Nom",
|
||||
"type": "Type de catégorie",
|
||||
"typePlaceholder": "Sélectionner un type"
|
||||
"types": "Types de catégorie"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Le nom est obligatoire.",
|
||||
"nameLength": "Le nom doit faire entre 2 et 120 caractères.",
|
||||
"typeRequired": "Le type de catégorie est obligatoire."
|
||||
"typesRequired": "Sélectionnez au moins un type de catégorie."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Supprimer la catégorie",
|
||||
@@ -432,9 +1017,68 @@
|
||||
"created": "Catégorie créée avec succès",
|
||||
"updated": "Catégorie mise à jour avec succès",
|
||||
"deleted": "Catégorie supprimée avec succès",
|
||||
"duplicate": "Une catégorie nommée « {name} » existe déjà pour ce type.",
|
||||
"duplicate": "Une catégorie nommée « {name} » existe déjà.",
|
||||
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
|
||||
}
|
||||
},
|
||||
"products": {
|
||||
"title": "Catalogue produit",
|
||||
"add": "Ajouter",
|
||||
"export": "Exporter",
|
||||
"empty": "Aucun produit pour l'instant.",
|
||||
"column": {
|
||||
"name": "Nom",
|
||||
"code": "Numéro",
|
||||
"category": "Catégorie"
|
||||
},
|
||||
"state": {
|
||||
"PURCHASE": "Achat",
|
||||
"SALE": "Vendu",
|
||||
"OTHER": "Autre"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"search": "Recherche",
|
||||
"category": "Catégorie",
|
||||
"categoryAll": "Toutes les catégories",
|
||||
"state": "État",
|
||||
"stateAll": "Tous les états",
|
||||
"site": "Sites",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
"form": {
|
||||
"title": "Ajouter un produit",
|
||||
"back": "Retour au catalogue",
|
||||
"submit": "Valider",
|
||||
"states": "État du produit",
|
||||
"sites": "Site",
|
||||
"name": "Nom du produit",
|
||||
"code": "Code produit",
|
||||
"category": "Catégorie produit",
|
||||
"storageTypes": "Type de stockage",
|
||||
"manufactured": "Fabriqué",
|
||||
"containsMolasses": "Contient de la mélasse",
|
||||
"duplicateCode": "Un produit portant ce code existe déjà."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le produit",
|
||||
"back": "Retour au catalogue",
|
||||
"save": "Enregistrer",
|
||||
"loading": "Chargement du produit…",
|
||||
"notFound": "Produit introuvable."
|
||||
},
|
||||
"tab": {
|
||||
"suppliers": "Fournisseurs",
|
||||
"clients": "Clients",
|
||||
"placeholder": "Cet onglet est en cours de développement"
|
||||
},
|
||||
"toast": {
|
||||
"error": "Une erreur est survenue. Réessayez.",
|
||||
"exportError": "L'export du catalogue produit a échoué. Réessayez.",
|
||||
"createSuccess": "Produit créé avec succès",
|
||||
"updateSuccess": "Produit mis à jour avec succès"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,27 +20,22 @@
|
||||
:label="t('admin.categories.form.name')"
|
||||
input-class="w-full"
|
||||
:max-length="120"
|
||||
:error="form.errors.value.name"
|
||||
:error="form.errors.name"
|
||||
required
|
||||
/>
|
||||
|
||||
<!-- Type (RG-1.05 obligatoire). MalioSelect porte la valeur en
|
||||
number (categoryType id) ; conversion en IRI au moment du save
|
||||
par le composable useCategoryForm. -->
|
||||
<MalioSelect
|
||||
v-model="form.categoryTypeId.value"
|
||||
<!-- Types (RG-1.05 : au moins un obligatoire). MalioSelectCheckbox
|
||||
porte un tableau d'ids (categoryType id) ; conversion en tableau
|
||||
d'IRI au moment du save par le composable useCategoryForm. -->
|
||||
<MalioSelectCheckbox
|
||||
v-model="form.categoryTypeIds.value"
|
||||
:options="typeOptions"
|
||||
:label="t('admin.categories.form.type')"
|
||||
:empty-option-label="t('admin.categories.form.typePlaceholder')"
|
||||
:error="form.errors.value.categoryType"
|
||||
:label="t('admin.categories.form.types')"
|
||||
:error="form.errors.categoryTypes"
|
||||
:display-tag="true"
|
||||
:disabled="loadingTypes"
|
||||
required
|
||||
/>
|
||||
|
||||
<!-- Erreur transverse (typiquement reseau / 5xx) — separe des
|
||||
erreurs de validation par champ. -->
|
||||
<p v-if="form.errors.value._global" class="text-sm text-red-600">
|
||||
{{ form.errors.value._global }}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<!-- Footer fixe : depuis 1.7.1 le slot #footer est un frere du body
|
||||
@@ -64,7 +59,7 @@
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canShowSave"
|
||||
:label="t('common.save')"
|
||||
:label="isCreateMode ? t('common.validate') : t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-m-btn-action"
|
||||
:disabled="form.submitting.value || loadingTypes"
|
||||
@@ -95,28 +90,17 @@ const emit = defineEmits<{
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
/**
|
||||
* Mode du drawer (dérivé du composable `useCategoryForm`) :
|
||||
* - 'create' : pas de category prop, formulaire vide, POST au save.
|
||||
* - 'view' : category prop set, formulaire pre-rempli, save MASQUE
|
||||
* jusqu'a ce que l'utilisateur modifie un champ.
|
||||
* - 'edit' : category prop set et formulaire « dirty » (au moins un
|
||||
* champ different de l'original), PATCH au save.
|
||||
*/
|
||||
type DrawerMode = 'create' | 'view' | 'edit'
|
||||
|
||||
// Mode du drawer : creation (pas de category prop, POST au save) ou
|
||||
// modification d'une categorie existante (PATCH au save). Pas de distinction
|
||||
// view/edit : comme les autres drawers, le titre et le bouton Enregistrer sont
|
||||
// stables quel que soit l'etat « dirty » du formulaire.
|
||||
const isCreateMode = computed(() => props.category === null)
|
||||
|
||||
const mode = computed<DrawerMode>(() => {
|
||||
if (isCreateMode.value) return 'create'
|
||||
return form.isDirty.value ? 'edit' : 'view'
|
||||
})
|
||||
|
||||
const headerLabel = computed(() => {
|
||||
if (mode.value === 'create') return t('admin.categories.createCategory')
|
||||
if (mode.value === 'edit') return t('admin.categories.editCategory')
|
||||
return t('admin.categories.viewCategory')
|
||||
})
|
||||
const headerLabel = computed(() =>
|
||||
isCreateMode.value
|
||||
? t('admin.categories.createCategory')
|
||||
: t('admin.categories.editCategory'),
|
||||
)
|
||||
|
||||
// Le bouton Supprimer n'est visible qu'en consultation/edition d'une categorie
|
||||
// existante et seulement pour les users ayant la permission manage. En mode
|
||||
@@ -125,10 +109,12 @@ const canShowDelete = computed(
|
||||
() => !isCreateMode.value && can('catalog.categories.manage'),
|
||||
)
|
||||
|
||||
// Save : visible en creation, ou en edition (apres modification d'un champ).
|
||||
// Masque en view tant que rien n'a change.
|
||||
// Save : visible en creation, et en consultation/edition d'une categorie
|
||||
// existante (l'utilisateur doit pouvoir enregistrer sans qu'un champ ait
|
||||
// d'abord ete modifie). Le bouton reste neanmoins protege par son `disabled`
|
||||
// pendant la soumission / le chargement des types.
|
||||
const canShowSave = computed(
|
||||
() => mode.value === 'create' || mode.value === 'edit',
|
||||
() => isCreateMode.value || can('catalog.categories.manage'),
|
||||
)
|
||||
|
||||
const typeOptions = computed(() =>
|
||||
@@ -158,18 +144,18 @@ watch(
|
||||
)
|
||||
|
||||
/**
|
||||
* Sauvegarde : delegue au composable (POST en mode create, PATCH en mode
|
||||
* edit). Le toast succes + mapping erreur 409/422 est gere par le composable.
|
||||
* En cas de succes, on ferme le drawer et on previent le parent pour qu'il
|
||||
* refresh la liste.
|
||||
* Sauvegarde : delegue au composable (POST en creation, PATCH en modification).
|
||||
* Le toast succes + mapping erreur 409/422 est gere par le composable. Le PATCH
|
||||
* envoie le payload complet, donc le bouton Enregistrer sauvegarde a tout
|
||||
* moment (meme sans modification). En cas de succes, on ferme le drawer et on
|
||||
* previent le parent pour qu'il refresh la liste.
|
||||
*/
|
||||
async function handleSave(): Promise<void> {
|
||||
let result: Category | null = null
|
||||
if (mode.value === 'create') {
|
||||
result = await form.submitCreate()
|
||||
} else if (mode.value === 'edit' && props.category) {
|
||||
result = await form.submitUpdate(props.category.id)
|
||||
}
|
||||
const result = isCreateMode.value
|
||||
? await form.submitCreate()
|
||||
: props.category
|
||||
? await form.submitUpdate(props.category.id)
|
||||
: null
|
||||
if (result) {
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<!--
|
||||
Onglets « Fournisseurs » / « Clients » de la fiche produit — HORS PERIMETRE
|
||||
V0 (HP-M6-01, RG-6.10) : ils dependent d'un module Contrat inexistant.
|
||||
Rendu en placeholder « en cours de développement » (meme composant que les
|
||||
onglets non-dev des fiches M1→M4). AUCUN appel API, AUCUN champ saisissable.
|
||||
Affiches sans condition d'etat (a raffiner avec le module Contrat).
|
||||
-->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||
<template #suppliers><ComingSoonPlaceholder :title="t('admin.products.tab.placeholder')" /></template>
|
||||
<template #clients><ComingSoonPlaceholder :title="t('admin.products.tab.placeholder')" /></template>
|
||||
</MalioTabList>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const activeTab = ref('suppliers')
|
||||
|
||||
// Icone (Iconify) par onglet, alignee sur la convention des fiches existantes.
|
||||
const tabs = computed(() => [
|
||||
{ key: 'suppliers', label: t('admin.products.tab.suppliers'), icon: 'mdi:truck-outline' },
|
||||
{ key: 'clients', label: t('admin.products.tab.clients'), icon: 'mdi:account-group-outline' },
|
||||
])
|
||||
</script>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||
import { useCategoryForm } from '../useCategoryForm'
|
||||
|
||||
// Stubs des auto-imports Nuxt consommes par le composable.
|
||||
@@ -21,6 +22,9 @@ vi.stubGlobal('useToast', () => ({
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
}))
|
||||
// useFormErrors est un auto-import Nuxt : on expose l'implementation reelle
|
||||
// (elle consomme useToast, deja stubbe ci-dessus) pour tester l'integration.
|
||||
vi.stubGlobal('useFormErrors', useFormErrors)
|
||||
// useI18n.t : on renvoie la cle telle quelle (pratique pour asserter dessus).
|
||||
// Quand le composable passe des params (ex: doublon), on les serialise pour
|
||||
// pouvoir verifier que l'interpolation a bien recu le bon nom.
|
||||
@@ -35,7 +39,7 @@ const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
|
||||
const CAT: Category = {
|
||||
id: 42,
|
||||
name: 'Vis',
|
||||
categoryType: TYPE_VENTE,
|
||||
categoryTypes: [TYPE_VENTE],
|
||||
deletedAt: null,
|
||||
createdAt: '2026-01-01T10:00:00+00:00',
|
||||
updatedAt: '2026-01-01T10:00:00+00:00',
|
||||
@@ -54,25 +58,25 @@ describe('useCategoryForm', () => {
|
||||
})
|
||||
|
||||
describe('loadFrom', () => {
|
||||
it('pre-remplit le formulaire depuis une categorie existante', () => {
|
||||
it('pre-remplit le formulaire depuis une categorie existante (multi-types)', () => {
|
||||
const form = useCategoryForm()
|
||||
|
||||
form.loadFrom(CAT)
|
||||
form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
|
||||
|
||||
expect(form.name.value).toBe('Vis')
|
||||
expect(form.categoryTypeId.value).toBe(1)
|
||||
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
|
||||
expect(form.categoryTypeIds.value).toEqual([1, 2])
|
||||
expect(form.errors).toEqual({})
|
||||
})
|
||||
|
||||
it('vide le formulaire en mode creation (null)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'old'
|
||||
form.categoryTypeId.value = 99
|
||||
form.categoryTypeIds.value = [99]
|
||||
|
||||
form.loadFrom(null)
|
||||
|
||||
expect(form.name.value).toBe('')
|
||||
expect(form.categoryTypeId.value).toBeNull()
|
||||
expect(form.categoryTypeIds.value).toEqual([])
|
||||
})
|
||||
|
||||
it('reinitialise le snapshot initial → isDirty=false juste apres', () => {
|
||||
@@ -94,100 +98,122 @@ describe('useCategoryForm', () => {
|
||||
|
||||
expect(form.isDirty.value).toBe(true)
|
||||
})
|
||||
|
||||
it('passe a true quand on ajoute un type (selection multi)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
expect(form.isDirty.value).toBe(false)
|
||||
|
||||
form.categoryTypeIds.value = [1, 2]
|
||||
|
||||
expect(form.isDirty.value).toBe(true)
|
||||
})
|
||||
|
||||
it('reste false si la selection est identique dans un autre ordre', () => {
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
|
||||
|
||||
form.categoryTypeIds.value = [2, 1]
|
||||
|
||||
expect(form.isDirty.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validate', () => {
|
||||
it('signale une erreur si name est vide (RG-1.02)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ''
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
|
||||
expect(form.errors.name).toBe('admin.categories.validation.nameRequired')
|
||||
})
|
||||
|
||||
it('signale erreur si name est whitespace-only (trim → vide)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ' '
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
|
||||
expect(form.errors.name).toBe('admin.categories.validation.nameRequired')
|
||||
})
|
||||
|
||||
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'A'
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
|
||||
expect(form.errors.name).toBe('admin.categories.validation.nameLength')
|
||||
})
|
||||
|
||||
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'A'.repeat(121)
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
|
||||
expect(form.errors.name).toBe('admin.categories.validation.nameLength')
|
||||
})
|
||||
|
||||
it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
|
||||
it('signale erreur si aucun type selectionne (RG-1.05)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = null
|
||||
form.categoryTypeIds.value = []
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.categoryType).toBe('admin.categories.validation.typeRequired')
|
||||
expect(form.errors.categoryTypes).toBe('admin.categories.validation.typesRequired')
|
||||
})
|
||||
|
||||
it('passe quand name et categoryType sont valides', () => {
|
||||
it('passe quand name et au moins un type sont valides', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1, 2]
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
|
||||
expect(form.errors).toEqual({})
|
||||
})
|
||||
|
||||
it('reinitialise les erreurs avant chaque validation', () => {
|
||||
const form = useCategoryForm()
|
||||
// Erreur prealable.
|
||||
form.errors.value._global = 'erreur ancienne'
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
// Erreur prealable : une validation en echec peuple errors.name.
|
||||
form.name.value = ''
|
||||
form.categoryTypeIds.value = [1]
|
||||
form.validate()
|
||||
expect(form.errors.name).toBeTruthy()
|
||||
|
||||
// Seconde validation avec des valeurs valides : errors repart vide.
|
||||
form.name.value = 'Vis'
|
||||
form.validate()
|
||||
|
||||
expect(form.errors.value._global).toBe('')
|
||||
expect(form.errors).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitCreate', () => {
|
||||
it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => {
|
||||
it('appelle POST /categories avec body { name trimme, categoryTypes en IRI[] }', async () => {
|
||||
mockPost.mockResolvedValueOnce(CAT)
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ' Vis '
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1, 2]
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/categories',
|
||||
{ name: 'Vis', categoryType: '/api/category_types/1' },
|
||||
{ name: 'Vis', categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
|
||||
{ toast: false },
|
||||
)
|
||||
expect(result).toEqual(CAT)
|
||||
@@ -196,7 +222,7 @@ describe('useCategoryForm', () => {
|
||||
it('ne declenche aucun appel API si la validation client echoue', async () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ''
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
@@ -208,12 +234,12 @@ describe('useCategoryForm', () => {
|
||||
mockPost.mockResolvedValueOnce(CAT)
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
await form.submitCreate()
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||
title: 'Succès',
|
||||
title: 'success.title',
|
||||
message: 'admin.categories.toast.created',
|
||||
})
|
||||
})
|
||||
@@ -224,15 +250,15 @@ describe('useCategoryForm', () => {
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(result).toBeNull()
|
||||
// La cle est interpolee avec le nom soumis : on retrouve "Vis" dans
|
||||
// les params i18n (stub serialise les params).
|
||||
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
|
||||
expect(form.errors.value.name).toContain('"name":"Vis"')
|
||||
expect(form.errors.name).toContain('admin.categories.toast.duplicate')
|
||||
expect(form.errors.name).toContain('"name":"Vis"')
|
||||
expect(mockToastError).toHaveBeenCalledTimes(1)
|
||||
const toastArg = mockToastError.mock.calls[0]?.[0] as { message: string }
|
||||
expect(toastArg.message).toContain('Vis')
|
||||
@@ -251,50 +277,51 @@ describe('useCategoryForm', () => {
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(form.errors.value.name).toBe('name should not be blank.')
|
||||
expect(form.errors.name).toBe('name should not be blank.')
|
||||
// Pas de toast quand on a mappe les violations : l erreur est
|
||||
// affichee inline sous le champ concerne.
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mappe aussi hydra:violations (negociation de format alternative)', async () => {
|
||||
it('mappe une violation sur categoryTypes (hydra:violations alternative)', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: {
|
||||
'hydra:violations': [
|
||||
{ propertyPath: 'categoryType', message: 'Type invalide.' },
|
||||
{ propertyPath: 'categoryTypes', message: 'Sélectionnez au moins un type de catégorie.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
await form.submitCreate()
|
||||
|
||||
expect(form.errors.value.categoryType).toBe('Type invalide.')
|
||||
expect(form.errors.categoryTypes).toBe('Sélectionnez au moins un type de catégorie.')
|
||||
})
|
||||
|
||||
it('fallback en erreur globale + toast si le status n est ni 409 ni 422', async () => {
|
||||
it('fallback en toast generique si le status n est ni 409 ni 422', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: { status: 500, _data: { 'hydra:description': 'Boom server' } },
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
await form.submitCreate()
|
||||
|
||||
expect(form.errors.value._global).toBe('Boom server')
|
||||
// Pas d'erreur inline par champ : l'erreur transverse part en toast.
|
||||
expect(form.errors).toEqual({})
|
||||
expect(mockToastError).toHaveBeenCalledWith({
|
||||
title: 'Erreur',
|
||||
title: 'errors.title',
|
||||
message: 'Boom server',
|
||||
})
|
||||
})
|
||||
@@ -306,7 +333,7 @@ describe('useCategoryForm', () => {
|
||||
)
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const pending = form.submitCreate()
|
||||
expect(form.submitting.value).toBe(true)
|
||||
@@ -319,45 +346,52 @@ describe('useCategoryForm', () => {
|
||||
})
|
||||
|
||||
describe('submitUpdate', () => {
|
||||
it('appelle PATCH /categories/{id} uniquement avec les champs modifies', async () => {
|
||||
it('appelle PATCH /categories/{id} avec le payload complet (name + categoryTypes)', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.name.value = 'Vis V2' // categoryTypeId inchange
|
||||
form.name.value = 'Vis V2' // types inchanges
|
||||
|
||||
await form.submitUpdate(42)
|
||||
|
||||
// Payload complet : meme si seul le name change, on renvoie aussi
|
||||
// les categoryTypes (PATCH full payload, cf. drawers simples).
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/categories/42',
|
||||
{ name: 'Vis V2', categoryTypes: ['/api/category_types/1'] },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('envoie categoryTypes en IRI[] quand on ajoute un type', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.categoryTypeIds.value = [1, 2]
|
||||
|
||||
await form.submitUpdate(42)
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/categories/42',
|
||||
{ name: 'Vis V2' }, // pas de categoryType car non modifie
|
||||
{ name: CAT.name, categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('envoie categoryType en IRI quand seul le type a change', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT })
|
||||
it('envoie un PATCH complet meme sans modification (save a tout moment)', async () => {
|
||||
mockPatch.mockResolvedValueOnce(CAT)
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.categoryTypeId.value = 2
|
||||
|
||||
await form.submitUpdate(42)
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/categories/42',
|
||||
{ categoryType: '/api/category_types/2' },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('court-circuite l appel API si aucun champ n a change', async () => {
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
// Aucune modification — isDirty=false, patch payload vide.
|
||||
// Aucune modification : le PATCH part quand meme avec le payload complet.
|
||||
|
||||
const result = await form.submitUpdate(42)
|
||||
|
||||
expect(mockPatch).not.toHaveBeenCalled()
|
||||
expect(result).toBeNull()
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/categories/42',
|
||||
{ name: CAT.name, categoryTypes: ['/api/category_types/1'] },
|
||||
{ toast: false },
|
||||
)
|
||||
expect(result).toEqual(CAT)
|
||||
expect(form.submitting.value).toBe(false)
|
||||
})
|
||||
|
||||
@@ -370,7 +404,7 @@ describe('useCategoryForm', () => {
|
||||
await form.submitUpdate(42)
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||
title: 'Succès',
|
||||
title: 'success.title',
|
||||
message: 'admin.categories.toast.updated',
|
||||
})
|
||||
})
|
||||
@@ -386,8 +420,8 @@ describe('useCategoryForm', () => {
|
||||
const result = await form.submitUpdate(42)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
|
||||
expect(form.errors.value.name).toContain('"name":"Doublon"')
|
||||
expect(form.errors.name).toContain('admin.categories.toast.duplicate')
|
||||
expect(form.errors.name).toContain('"name":"Doublon"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -401,7 +435,7 @@ describe('useCategoryForm', () => {
|
||||
expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false })
|
||||
expect(ok).toBe(true)
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||
title: 'Succès',
|
||||
title: 'success.title',
|
||||
message: 'admin.categories.toast.deleted',
|
||||
})
|
||||
})
|
||||
@@ -415,7 +449,6 @@ describe('useCategoryForm', () => {
|
||||
const ok = await form.submitDelete(42)
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value._global).toBe('down')
|
||||
expect(mockToastError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -424,15 +457,15 @@ describe('useCategoryForm', () => {
|
||||
it('vide le formulaire et les erreurs', () => {
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.name.value = 'edit'
|
||||
form.errors.value._global = 'erreur'
|
||||
form.name.value = ''
|
||||
form.validate() // peuple errors.name
|
||||
form.submitting.value = true
|
||||
|
||||
form.reset()
|
||||
|
||||
expect(form.name.value).toBe('')
|
||||
expect(form.categoryTypeId.value).toBeNull()
|
||||
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
|
||||
expect(form.categoryTypeIds.value).toEqual([])
|
||||
expect(form.errors).toEqual({})
|
||||
expect(form.submitting.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||
import { useProductForm } from '../useProductForm'
|
||||
|
||||
// Stubs des auto-imports Nuxt consommes par le composable + ses dependances.
|
||||
const mockGet = vi.hoisted(() => vi.fn())
|
||||
const mockPost = vi.hoisted(() => vi.fn())
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
const mockToastSuccess = vi.hoisted(() => vi.fn())
|
||||
const mockToastError = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useApi', () => ({
|
||||
get: mockGet,
|
||||
post: mockPost,
|
||||
put: vi.fn(),
|
||||
patch: mockPatch,
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal('useToast', () => ({
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
}))
|
||||
// useFormErrors est un auto-import Nuxt : on expose l'implementation reelle
|
||||
// (elle consomme useToast/useI18n deja stubbes) pour tester l'integration 422/409.
|
||||
vi.stubGlobal('useFormErrors', useFormErrors)
|
||||
vi.stubGlobal('useI18n', () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}::${JSON.stringify(params)}` : key,
|
||||
}))
|
||||
|
||||
/** Reponse Hydra des types de stockage selon les sites demandes. */
|
||||
function storageMembersForSites(siteIds: string[]): { member: Array<{ '@id': string, label: string }> } {
|
||||
// Site 1 → types 9 et 5 ; site 2 → type 7. Permet de tester la cascade.
|
||||
const byId: Record<string, Array<{ '@id': string, label: string }>> = {
|
||||
'1': [
|
||||
{ '@id': '/api/storage_types/9', label: 'Tas' },
|
||||
{ '@id': '/api/storage_types/5', label: 'Cellule' },
|
||||
],
|
||||
'2': [{ '@id': '/api/storage_types/7', label: 'Cuve mélasse' }],
|
||||
}
|
||||
const seen = new Map<string, { '@id': string, label: string }>()
|
||||
for (const id of siteIds) {
|
||||
for (const m of byId[id] ?? []) {
|
||||
seen.set(m['@id'], m)
|
||||
}
|
||||
}
|
||||
return { member: [...seen.values()] }
|
||||
}
|
||||
|
||||
describe('useProductForm', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
mockToastSuccess.mockReset()
|
||||
mockToastError.mockReset()
|
||||
|
||||
// Routage des GET par url (referentiels + cascade stockage).
|
||||
mockGet.mockImplementation((url: string, query: Record<string, unknown> = {}) => {
|
||||
if (url === '/sites') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
|
||||
}
|
||||
if (url === '/categories') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/categories/12', name: 'Céréales' }] })
|
||||
}
|
||||
if (url === '/storage_types') {
|
||||
const raw = (query['siteId[]'] ?? []) as string[]
|
||||
return Promise.resolve(storageMembersForSites(raw))
|
||||
}
|
||||
return Promise.resolve({ member: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('RG-6.03 — champs conditionnels « Vendu »', () => {
|
||||
it('isSale est vrai uniquement si states contient SALE', () => {
|
||||
const { form, isSale } = useProductForm()
|
||||
expect(isSale.value).toBe(false)
|
||||
form.states = ['PURCHASE']
|
||||
expect(isSale.value).toBe(false)
|
||||
form.states = ['PURCHASE', 'SALE']
|
||||
expect(isSale.value).toBe(true)
|
||||
})
|
||||
|
||||
it('remet manufactured / containsMolasses a false quand SALE est retire', async () => {
|
||||
const { form, isSale } = useProductForm()
|
||||
form.states = ['SALE']
|
||||
form.manufactured = true
|
||||
form.containsMolasses = true
|
||||
await nextTick()
|
||||
expect(isSale.value).toBe(true)
|
||||
|
||||
form.states = ['PURCHASE']
|
||||
await nextTick()
|
||||
expect(form.manufactured).toBe(false)
|
||||
expect(form.containsMolasses).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RG-6.06 — cascade Site → Type de stockage', () => {
|
||||
it('charge les types de stockage filtres par les sites selectionnes', async () => {
|
||||
const { storageTypeOptions, setSites } = useProductForm()
|
||||
await setSites(['/api/sites/1'])
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/storage_types',
|
||||
expect.objectContaining({ 'siteId[]': ['1'], pagination: 'false' }),
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(storageTypeOptions.value.map(o => o.value)).toEqual([
|
||||
'/api/storage_types/9',
|
||||
'/api/storage_types/5',
|
||||
])
|
||||
})
|
||||
|
||||
it('retire de la selection les types devenus indisponibles', async () => {
|
||||
const { form, setStorageTypes, setSites } = useProductForm()
|
||||
|
||||
// Selection initiale sur le site 1 (types 9 et 5).
|
||||
await setSites(['/api/sites/1'])
|
||||
setStorageTypes(['/api/storage_types/9', '/api/storage_types/5'])
|
||||
|
||||
// Bascule vers le site 2 (type 7 seul) : 9 et 5 ne sont plus dispo.
|
||||
await setSites(['/api/sites/2'])
|
||||
expect(form.storageTypeIris).toEqual([])
|
||||
})
|
||||
|
||||
it('vide options + selection quand plus aucun site n\'est selectionne', async () => {
|
||||
const { form, storageTypeOptions, setStorageTypes, setSites } = useProductForm()
|
||||
await setSites(['/api/sites/1'])
|
||||
setStorageTypes(['/api/storage_types/9'])
|
||||
|
||||
await setSites([])
|
||||
expect(storageTypeOptions.value).toEqual([])
|
||||
expect(form.storageTypeIris).toEqual([])
|
||||
// Pas d'appel /storage_types inutile sans site.
|
||||
expect(mockGet).not.toHaveBeenCalledWith('/storage_types', expect.objectContaining({ 'siteId[]': [] }), expect.any(Object))
|
||||
})
|
||||
})
|
||||
|
||||
describe('submit — POST /products', () => {
|
||||
function fillValidForm(form: ReturnType<typeof useProductForm>['form']): void {
|
||||
form.code = 'ble-01'
|
||||
form.name = 'Blé tendre'
|
||||
form.states = ['PURCHASE', 'SALE']
|
||||
form.siteIris = ['/api/sites/1']
|
||||
form.categoryIri = '/api/categories/12'
|
||||
form.storageTypeIris = ['/api/storage_types/9']
|
||||
form.manufactured = true
|
||||
form.containsMolasses = false
|
||||
}
|
||||
|
||||
it('poste le payload (relations en IRI) et retourne true au succes', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 34 })
|
||||
const { form, submit } = useProductForm()
|
||||
fillValidForm(form)
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/products',
|
||||
{
|
||||
code: 'ble-01',
|
||||
name: 'Blé tendre',
|
||||
states: ['PURCHASE', 'SALE'],
|
||||
manufactured: true,
|
||||
containsMolasses: false,
|
||||
category: '/api/categories/12',
|
||||
sites: ['/api/sites/1'],
|
||||
storageTypes: ['/api/storage_types/9'],
|
||||
},
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
expect(mockToastSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('force manufactured / containsMolasses a false hors « Vendu » (RG-6.03)', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 35 })
|
||||
const { form, submit } = useProductForm()
|
||||
fillValidForm(form)
|
||||
// L'utilisateur retire « Vendu » apres avoir coche les booleens.
|
||||
form.states = ['PURCHASE']
|
||||
|
||||
await submit()
|
||||
|
||||
const payload = mockPost.mock.calls[0][1]
|
||||
expect(payload.manufactured).toBe(false)
|
||||
expect(payload.containsMolasses).toBe(false)
|
||||
})
|
||||
|
||||
it('mappe un 409 doublon de code sur errors.code + toast explicite', async () => {
|
||||
mockPost.mockRejectedValueOnce({ response: { status: 409, _data: {} } })
|
||||
const { form, errors, submit } = useProductForm()
|
||||
fillValidForm(form)
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(errors.code).toBe('admin.products.form.duplicateCode')
|
||||
expect(mockToastError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mappe une 422 inline par champ (errors.code) sans toast', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'code', message: 'Le code produit est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
const { form, errors, submit } = useProductForm()
|
||||
fillValidForm(form)
|
||||
form.code = null
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(errors.code).toBe('Le code produit est obligatoire.')
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RG-6.08 — mode edition (prefill + PATCH)', () => {
|
||||
// Produit charge (memes cles que la reponse reelle § 4.0.bis : @id sur les relations).
|
||||
const PRODUCT = {
|
||||
id: 34,
|
||||
code: 'BLE-01',
|
||||
name: 'Blé tendre',
|
||||
states: ['PURCHASE', 'SALE'],
|
||||
manufactured: true,
|
||||
containsMolasses: false,
|
||||
category: { '@id': '/api/categories/12', id: 12, name: 'Céréales', code: 'CEREALES' },
|
||||
sites: [{ '@id': '/api/sites/1', id: 1, name: 'Chatellerault', code: '86', postalCode: '86100', city: 'C', color: '#000', fullAddress: 'x' }],
|
||||
storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }],
|
||||
createdAt: '', updatedAt: '',
|
||||
}
|
||||
|
||||
it('pre-remplit le formulaire depuis le produit (relations en IRI) + charge le stockage', async () => {
|
||||
const { form, prefill, storageTypeOptions } = useProductForm()
|
||||
await prefill(PRODUCT)
|
||||
|
||||
expect(form.code).toBe('BLE-01')
|
||||
expect(form.name).toBe('Blé tendre')
|
||||
expect(form.states).toEqual(['PURCHASE', 'SALE'])
|
||||
expect(form.categoryIri).toBe('/api/categories/12')
|
||||
expect(form.siteIris).toEqual(['/api/sites/1'])
|
||||
expect(form.storageTypeIris).toEqual(['/api/storage_types/9'])
|
||||
expect(form.manufactured).toBe(true)
|
||||
// Cascade : options de stockage chargees pour le site du produit.
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/storage_types',
|
||||
expect.objectContaining({ 'siteId[]': ['1'] }),
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(storageTypeOptions.value.map(o => o.value)).toContain('/api/storage_types/9')
|
||||
})
|
||||
|
||||
it('soumet un PATCH /products/{id} apres prefill (RG-6.08)', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ id: 34 })
|
||||
const { prefill, submit } = useProductForm()
|
||||
await prefill(PRODUCT)
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/products/34',
|
||||
expect.objectContaining({ code: 'BLE-01', name: 'Blé tendre' }),
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
expect(mockToastSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mappe un 409 doublon de code aussi en edition', async () => {
|
||||
mockPatch.mockRejectedValueOnce({ response: { status: 409, _data: {} } })
|
||||
const { errors, prefill, submit } = useProductForm()
|
||||
await prefill(PRODUCT)
|
||||
|
||||
const ok = await submit()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(errors.code).toBe('admin.products.form.duplicateCode')
|
||||
expect(mockToastError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,14 +12,14 @@
|
||||
* elles servent juste a eviter l'aller-retour reseau evitable. Le serveur
|
||||
* revalide toujours (defense en profondeur).
|
||||
*
|
||||
* Mapping erreurs API :
|
||||
* - 409 (RG-1.07 doublon) → toast + erreur sur le champ `name`
|
||||
* - 422 (violations API Platform) → mapping sur les champs concernes
|
||||
* - autre → erreur globale `_global` + toast generique
|
||||
* Erreurs par champ : delegue a `useFormErrors` (convention ERP-101). Les
|
||||
* violations 422 sont mappees par `propertyPath` (`name`, `categoryTypes`) ;
|
||||
* l'erreur globale (status != 422 exploitable) part en toast. Le 409 (doublon
|
||||
* de nom GLOBAL, RG-1.07) reste un cas metier specifique : erreur inline sur
|
||||
* `name` + toast.
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Category } from '~/modules/catalog/types/category'
|
||||
import { extractApiErrorMessage, extractApiViolations } from '~/shared/utils/api'
|
||||
|
||||
/**
|
||||
* Erreur HTTP capturee par ofetch. On expose juste les champs utilises ici
|
||||
@@ -37,33 +37,35 @@ export function useCategoryForm() {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
// Etat d'erreurs par champ (indexe par propertyPath) + dispatch API 422.
|
||||
const formErrors = useFormErrors()
|
||||
|
||||
// State local du formulaire — pas singleton, chaque appel a useCategoryForm
|
||||
// cree son propre state (cohérent avec le pattern « un drawer = un form »).
|
||||
const name = ref('')
|
||||
const categoryTypeId = ref<number | null>(null)
|
||||
const categoryTypeIds = ref<number[]>([])
|
||||
|
||||
// Snapshot des valeurs initiales : sert a calculer `isDirty` pour le
|
||||
// pattern view → edit du drawer (le bouton Enregistrer reste masque tant
|
||||
// que rien n'a change en mode consultation).
|
||||
const initialName = ref('')
|
||||
const initialCategoryTypeId = ref<number | null>(null)
|
||||
|
||||
const errors = ref<{
|
||||
name: string
|
||||
categoryType: string
|
||||
_global: string
|
||||
}>({
|
||||
name: '',
|
||||
categoryType: '',
|
||||
_global: '',
|
||||
})
|
||||
const initialCategoryTypeIds = ref<number[]>([])
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
// Compare deux listes d'ids sans tenir compte de l'ordre (la selection
|
||||
// multi-types n'est pas ordonnee).
|
||||
function sameIds(a: number[], b: number[]): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
const sortedA = [...a].sort((x, y) => x - y)
|
||||
const sortedB = [...b].sort((x, y) => x - y)
|
||||
return sortedA.every((v, i) => v === sortedB[i])
|
||||
}
|
||||
|
||||
const isDirty = computed(
|
||||
() =>
|
||||
name.value !== initialName.value
|
||||
|| categoryTypeId.value !== initialCategoryTypeId.value,
|
||||
|| !sameIds(categoryTypeIds.value, initialCategoryTypeIds.value),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -72,17 +74,18 @@ export function useCategoryForm() {
|
||||
* erreurs et le snapshot initial pour repartir d'un etat propre.
|
||||
*/
|
||||
function loadFrom(category: Category | null): void {
|
||||
errors.value = { name: '', categoryType: '', _global: '' }
|
||||
formErrors.clearErrors()
|
||||
if (category) {
|
||||
const ids = category.categoryTypes.map(t => t.id)
|
||||
name.value = category.name
|
||||
categoryTypeId.value = category.categoryType.id
|
||||
categoryTypeIds.value = [...ids]
|
||||
initialName.value = category.name
|
||||
initialCategoryTypeId.value = category.categoryType.id
|
||||
initialCategoryTypeIds.value = [...ids]
|
||||
} else {
|
||||
name.value = ''
|
||||
categoryTypeId.value = null
|
||||
categoryTypeIds.value = []
|
||||
initialName.value = ''
|
||||
initialCategoryTypeId.value = null
|
||||
initialCategoryTypeIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,107 +95,56 @@ export function useCategoryForm() {
|
||||
* mais le serveur retrim de toute facon — pas de risque de divergence.
|
||||
*/
|
||||
function validate(): boolean {
|
||||
errors.value = { name: '', categoryType: '', _global: '' }
|
||||
formErrors.clearErrors()
|
||||
const trimmedName = name.value.trim()
|
||||
|
||||
// RG-1.02 — name obligatoire (vide / whitespace-only).
|
||||
if (trimmedName === '') {
|
||||
errors.value.name = t('admin.categories.validation.nameRequired')
|
||||
formErrors.setError('name', t('admin.categories.validation.nameRequired'))
|
||||
} else if (trimmedName.length < 2 || trimmedName.length > 120) {
|
||||
// RG-1.04 — longueur 2-120 apres trim.
|
||||
errors.value.name = t('admin.categories.validation.nameLength')
|
||||
formErrors.setError('name', t('admin.categories.validation.nameLength'))
|
||||
}
|
||||
|
||||
// RG-1.05 — categoryType obligatoire.
|
||||
if (categoryTypeId.value === null) {
|
||||
errors.value.categoryType = t('admin.categories.validation.typeRequired')
|
||||
// RG-1.05 — au moins un type obligatoire.
|
||||
if (categoryTypeIds.value.length === 0) {
|
||||
formErrors.setError('categoryTypes', t('admin.categories.validation.typesRequired'))
|
||||
}
|
||||
|
||||
return errors.value.name === '' && errors.value.categoryType === ''
|
||||
return !formErrors.errors.name && !formErrors.errors.categoryTypes
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le payload POST a partir du state. Le `categoryType` est
|
||||
* envoye en IRI Hydra (`/api/category_types/{id}`) — convention API
|
||||
* Platform pour referencer une ressource liee. Retourne un object literal
|
||||
* compatible avec `AnyObject` de `useApi()` (un type nomme strict comme
|
||||
* `CategoryCreateInput` ne serait pas assignable a `Record<string, unknown>`
|
||||
* en TS strict).
|
||||
* Construit le payload POST a partir du state. Les `categoryTypes` sont
|
||||
* envoyes en tableau d'IRI Hydra (`/api/category_types/{id}`) — convention
|
||||
* API Platform pour referencer une collection de ressources liees.
|
||||
*/
|
||||
function buildCreatePayload(): Record<string, unknown> {
|
||||
return {
|
||||
name: name.value.trim(),
|
||||
categoryType: `/api/category_types/${categoryTypeId.value}`,
|
||||
categoryTypes: categoryTypeIds.value.map(id => `/api/category_types/${id}`),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappe les violations 422 d'API Platform sur les champs du formulaire.
|
||||
* Renvoie true des qu'au moins une violation a ete posee — false sinon
|
||||
* (payload sans violations exploitables, ou tous les `propertyPath` hors
|
||||
* du mapping connu). L'extraction Hydra (`violations` / `hydra:violations`)
|
||||
* est centralisee dans `shared/utils/api.ts` pour rester reutilisable
|
||||
* sur les futurs drawers de formulaire.
|
||||
*/
|
||||
function mapServerViolations(data: unknown): boolean {
|
||||
const violations = extractApiViolations(data)
|
||||
if (violations.length === 0) return false
|
||||
let mapped = false
|
||||
for (const v of violations) {
|
||||
if (v.propertyPath === 'name') {
|
||||
errors.value.name = v.message
|
||||
mapped = true
|
||||
} else if (v.propertyPath === 'categoryType') {
|
||||
errors.value.categoryType = v.message
|
||||
mapped = true
|
||||
}
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
/**
|
||||
* Traite une erreur API : mappe selon le status, declenche les toasts
|
||||
* appropries. Centralise la logique entre create/update.
|
||||
*
|
||||
* - 409 (RG-1.07) : doublon — toast + errors.name avec libelle qui inclut
|
||||
* le nom soumis.
|
||||
* - 422 : tentative de mapping fin via les violations API Platform — si au
|
||||
* moins une violation est mappee, pas de toast (erreur affichee inline
|
||||
* sous le champ concerne).
|
||||
* - autre : message global + toast generique. Le toast natif d'useApi
|
||||
* est desactive (`toast: false`) pour permettre ce mapping fin ; il faut
|
||||
* donc en re-emettre un manuellement ici, sinon une 500 reste silencieuse.
|
||||
*
|
||||
* Retourne true si l'erreur a ete reconnue et traitee (409/422 mappes),
|
||||
* false sinon (fallback generique).
|
||||
* Traite une erreur API : 409 (doublon RG-1.07) → erreur inline sur `name`
|
||||
* + toast ; sinon delegue a `useFormErrors.handleApiError` (422 mappe inline
|
||||
* par champ sans toast, autre → toast de fallback). Retourne true si traitee
|
||||
* inline (409/422 mappe), false si fallback toast.
|
||||
*/
|
||||
function handleApiError(e: unknown, attemptedName: string): boolean {
|
||||
const status = (e as ApiFetchError)?.response?.status
|
||||
const data = (e as ApiFetchError)?.response?._data
|
||||
|
||||
if (status === 409) {
|
||||
const duplicateMessage = t('admin.categories.toast.duplicate', {
|
||||
name: attemptedName,
|
||||
})
|
||||
errors.value.name = duplicateMessage
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: duplicateMessage,
|
||||
})
|
||||
formErrors.setError('name', duplicateMessage)
|
||||
toast.error({ title: t('errors.title'), message: duplicateMessage })
|
||||
return true
|
||||
}
|
||||
|
||||
if (status === 422 && mapServerViolations(data)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const extracted = extractApiErrorMessage(data)
|
||||
errors.value._global = extracted || 'Une erreur est survenue.'
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: errors.value._global,
|
||||
})
|
||||
return false
|
||||
return formErrors.handleApiError(e, { fallbackMessage: t('errors.generic') })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,14 +155,13 @@ export function useCategoryForm() {
|
||||
async function submitCreate(): Promise<Category | null> {
|
||||
if (!validate()) return null
|
||||
submitting.value = true
|
||||
errors.value._global = ''
|
||||
const payload = buildCreatePayload()
|
||||
try {
|
||||
const created = await api.post<Category>('/categories', payload, {
|
||||
toast: false,
|
||||
})
|
||||
toast.success({
|
||||
title: 'Succès',
|
||||
title: t('success.title'),
|
||||
message: t('admin.categories.toast.created'),
|
||||
})
|
||||
return created
|
||||
@@ -223,34 +174,25 @@ export function useCategoryForm() {
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/categories/{id}. Envoie uniquement les champs modifies pour
|
||||
* coller a la semantique merge-patch (Content-Type pose par useApi).
|
||||
* Renvoie la categorie mise a jour, ou `null` en cas d'echec.
|
||||
* PATCH /api/categories/{id}. Envoie le payload complet (name +
|
||||
* categoryTypes), comme les autres drawers du projet : le bouton
|
||||
* Enregistrer sauvegarde a tout moment, meme sans modification, et renvoie
|
||||
* toujours un retour (toast succes + refresh). Renvoie la categorie mise a
|
||||
* jour, ou `null` en cas d'echec.
|
||||
*/
|
||||
async function submitUpdate(id: number): Promise<Category | null> {
|
||||
if (!validate()) return null
|
||||
submitting.value = true
|
||||
errors.value._global = ''
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (name.value !== initialName.value) {
|
||||
payload.name = name.value.trim()
|
||||
}
|
||||
if (categoryTypeId.value !== initialCategoryTypeId.value) {
|
||||
payload.categoryType = `/api/category_types/${categoryTypeId.value}`
|
||||
}
|
||||
// Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
|
||||
// empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
|
||||
// on protege le composable contre un appel direct mal utilise.
|
||||
if (Object.keys(payload).length === 0) {
|
||||
submitting.value = false
|
||||
return null
|
||||
const payload: Record<string, unknown> = {
|
||||
name: name.value.trim(),
|
||||
categoryTypes: categoryTypeIds.value.map(id => `/api/category_types/${id}`),
|
||||
}
|
||||
try {
|
||||
const updated = await api.patch<Category>(`/categories/${id}`, payload, {
|
||||
toast: false,
|
||||
})
|
||||
toast.success({
|
||||
title: 'Succès',
|
||||
title: t('success.title'),
|
||||
message: t('admin.categories.toast.updated'),
|
||||
})
|
||||
return updated
|
||||
@@ -272,11 +214,11 @@ export function useCategoryForm() {
|
||||
*/
|
||||
async function submitDelete(id: number): Promise<boolean> {
|
||||
submitting.value = true
|
||||
errors.value._global = ''
|
||||
formErrors.clearErrors()
|
||||
try {
|
||||
await api.delete(`/categories/${id}`, {}, { toast: false })
|
||||
toast.success({
|
||||
title: 'Succès',
|
||||
title: t('success.title'),
|
||||
message: t('admin.categories.toast.deleted'),
|
||||
})
|
||||
return true
|
||||
@@ -294,18 +236,18 @@ export function useCategoryForm() {
|
||||
*/
|
||||
function reset(): void {
|
||||
name.value = ''
|
||||
categoryTypeId.value = null
|
||||
categoryTypeIds.value = []
|
||||
initialName.value = ''
|
||||
initialCategoryTypeId.value = null
|
||||
errors.value = { name: '', categoryType: '', _global: '' }
|
||||
initialCategoryTypeIds.value = []
|
||||
formErrors.clearErrors()
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
name,
|
||||
categoryTypeId,
|
||||
errors,
|
||||
categoryTypeIds,
|
||||
errors: formErrors.errors,
|
||||
submitting,
|
||||
isDirty,
|
||||
// Methods
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Product } from '~/modules/catalog/types/product'
|
||||
|
||||
/**
|
||||
* Chargement d'un produit unique (ecran « Modification produit », M6 — ERP-206).
|
||||
* Lit le detail via `GET /api/products/{id}` — meme structure que la ligne de
|
||||
* liste (category / sites / storageTypes embarques, § 4.0.bis).
|
||||
*
|
||||
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload
|
||||
* Hydra complet (IRI `@id` des relations, necessaires au pre-remplissage des
|
||||
* selects). Etat 100 % local a l'instance (refs) — aucune persistance URL.
|
||||
*/
|
||||
export function useProduct(id: number | string) {
|
||||
const api = useApi()
|
||||
|
||||
const product = ref<Product | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
|
||||
/** Charge le detail du produit. En cas d'echec : `error = true`, `product = null`. */
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
product.value = await api.get<Product>(
|
||||
`/products/${id}`,
|
||||
{},
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
}
|
||||
catch {
|
||||
error.value = true
|
||||
product.value = null
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { product, loading, error, load }
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Composable du formulaire de creation produit (M6 — ERP-205).
|
||||
*
|
||||
* Porte l'etat du formulaire principal, les referentiels des selects, les regles
|
||||
* de gestion front (champs conditionnels RG-6.03, cascade site→stockage RG-6.06)
|
||||
* et la soumission `POST /api/products` avec mapping des erreurs 422/409 inline
|
||||
* (useFormErrors). Reference : ecran « Ajouter un client » / « Ajouter un
|
||||
* prestataire » (formulaire principal).
|
||||
*
|
||||
* Etat 100 % local a l'instance.
|
||||
*/
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import {
|
||||
useSiteOptions,
|
||||
useCategoryOptions,
|
||||
useStorageTypeOptions,
|
||||
} from '~/modules/catalog/composables/useProductOptions'
|
||||
import type { Product } from '~/modules/catalog/types/product'
|
||||
|
||||
/** Etats produit (miroir de l'enum back Product::STATE_*). */
|
||||
export const PRODUCT_STATES = ['PURCHASE', 'SALE', 'OTHER'] as const
|
||||
|
||||
/** Extrait l'id numerique d'un IRI Hydra (`/api/sites/1` → 1), sinon null. */
|
||||
function iriToId(iri: string): number | null {
|
||||
const tail = iri.split('/').pop()
|
||||
return tail !== undefined && /^\d+$/.test(tail) ? Number(tail) : null
|
||||
}
|
||||
|
||||
export function useProductForm() {
|
||||
const api = useApi()
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const formErrors = useFormErrors()
|
||||
|
||||
const sites = useSiteOptions()
|
||||
const categories = useCategoryOptions({ typeCode: 'PRODUIT' })
|
||||
const storage = useStorageTypeOptions()
|
||||
|
||||
// ── Etat du formulaire ───────────────────────────────────────────────────
|
||||
// Les relations sont stockees en IRI (envoyees telles quelles au POST) ;
|
||||
// `states` porte les codes enum ; les booleens conditionnels RG-6.03 a part.
|
||||
const form = reactive({
|
||||
code: null as string | null,
|
||||
name: null as string | null,
|
||||
states: [] as string[],
|
||||
siteIris: [] as string[],
|
||||
categoryIri: null as string | null,
|
||||
storageTypeIris: [] as string[],
|
||||
manufactured: false,
|
||||
containsMolasses: false,
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
// Id du produit edite (null = creation). Pilote l'URL/methode du submit (RG-6.08 :
|
||||
// « Modification » = meme formulaire/regles que « Ajouter », bouton « Enregistrer »).
|
||||
const productId = ref<number | null>(null)
|
||||
|
||||
// RG-6.03 : « Fabriqué » / « Contient de la mélasse » saisissables uniquement
|
||||
// si l'etat contient « Vendu » (SALE).
|
||||
const isSale = computed(() => form.states.includes('SALE'))
|
||||
|
||||
// Quand l'etat ne contient plus SALE, on remet les booleens a false : le back
|
||||
// les forcerait de toute facon (RG-6.03), on evite de soumettre une valeur
|
||||
// fantome saisie avant de retirer « Vendu ».
|
||||
watch(isSale, (sale) => {
|
||||
if (!sale) {
|
||||
form.manufactured = false
|
||||
form.containsMolasses = false
|
||||
}
|
||||
})
|
||||
|
||||
/** Met a jour les etats (multi-select). */
|
||||
function setStates(states: string[]): void {
|
||||
form.states = states
|
||||
}
|
||||
|
||||
/** Met a jour la categorie (select simple). */
|
||||
function setCategory(iri: string | null): void {
|
||||
form.categoryIri = iri
|
||||
}
|
||||
|
||||
/** Met a jour les types de stockage (multi-select). */
|
||||
function setStorageTypes(iris: string[]): void {
|
||||
form.storageTypeIris = iris
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-6.06 (cascade) : a chaque changement de Site, recharge les options de Type
|
||||
* de stockage filtrees par les sites choisis et retire de la selection les
|
||||
* types devenus indisponibles.
|
||||
*/
|
||||
async function setSites(iris: string[]): Promise<void> {
|
||||
form.siteIris = iris
|
||||
const siteIds = iris
|
||||
.map(iriToId)
|
||||
.filter((id): id is number => id !== null)
|
||||
|
||||
await storage.load(siteIds)
|
||||
|
||||
const available = new Set(storage.options.value.map(o => o.value))
|
||||
form.storageTypeIris = form.storageTypeIris.filter(iri => available.has(iri))
|
||||
}
|
||||
|
||||
/** Charge les referentiels initiaux (sites + categories). Resilient. */
|
||||
async function loadReferentials(): Promise<void> {
|
||||
await Promise.allSettled([sites.load(), categories.load()])
|
||||
// Les types de stockage se chargent a la 1re selection de sites (cascade).
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-remplit le formulaire depuis un produit charge (mode edition, RG-6.08).
|
||||
* Les relations sont reprises via leur IRI `@id` (= valeur d'option des selects).
|
||||
* Charge au passage les options de Type de stockage pour les sites du produit,
|
||||
* afin que le multi-select affiche les libelles et conserve la selection.
|
||||
*/
|
||||
async function prefill(product: Product): Promise<void> {
|
||||
productId.value = product.id
|
||||
form.code = product.code
|
||||
form.name = product.name
|
||||
form.states = [...product.states]
|
||||
form.categoryIri = product.category?.['@id'] ?? null
|
||||
form.siteIris = product.sites.map(s => s['@id'])
|
||||
form.manufactured = product.manufactured
|
||||
form.containsMolasses = product.containsMolasses
|
||||
|
||||
const siteIds = form.siteIris
|
||||
.map(iriToId)
|
||||
.filter((id): id is number => id !== null)
|
||||
await storage.load(siteIds)
|
||||
form.storageTypeIris = product.storageTypes.map(st => st['@id'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Soumet le formulaire. Retourne true au succes (la page redirige), false sinon.
|
||||
* Creation → `POST /products` ; edition (productId non nul, RG-6.08) →
|
||||
* `PATCH /products/{id}` (mode merge-patch gere par useApi). 422 → mapping
|
||||
* inline par champ (useFormErrors) ; 409 doublon de code → erreur inline sur
|
||||
* `code` + toast explicite (RG-6.01, unicite re-validee aussi en edition).
|
||||
*/
|
||||
async function submit(): Promise<boolean> {
|
||||
if (submitting.value) {
|
||||
return false
|
||||
}
|
||||
submitting.value = true
|
||||
formErrors.clearErrors()
|
||||
const editing = productId.value !== null
|
||||
try {
|
||||
const payload = {
|
||||
code: form.code || null,
|
||||
name: form.name || null,
|
||||
states: form.states,
|
||||
// RG-6.03 : booleens forces a false hors « Vendu » (le back les
|
||||
// re-force, on garde le payload coherent).
|
||||
manufactured: isSale.value ? form.manufactured : false,
|
||||
containsMolasses: isSale.value ? form.containsMolasses : false,
|
||||
category: form.categoryIri,
|
||||
sites: form.siteIris,
|
||||
storageTypes: form.storageTypeIris,
|
||||
}
|
||||
const options = { headers: { Accept: 'application/ld+json' }, toast: false }
|
||||
if (editing) {
|
||||
await api.patch(`/products/${productId.value}`, payload, options)
|
||||
toast.success({ title: t('admin.products.toast.updateSuccess') })
|
||||
}
|
||||
else {
|
||||
await api.post('/products', payload, options)
|
||||
toast.success({ title: t('admin.products.toast.createSuccess') })
|
||||
}
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) {
|
||||
// Doublon de code (RG-6.01) : inline sur le champ + toast explicite.
|
||||
const message = t('admin.products.form.duplicateCode')
|
||||
formErrors.setError('code', message)
|
||||
toast.error({ title: t('admin.products.toast.error'), message })
|
||||
}
|
||||
else {
|
||||
formErrors.handleApiError(error, { fallbackMessage: t('admin.products.toast.error') })
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
productId,
|
||||
errors: formErrors.errors,
|
||||
submitting,
|
||||
isSale,
|
||||
siteOptions: sites.options,
|
||||
categoryOptions: categories.options,
|
||||
storageTypeOptions: storage.options,
|
||||
setStates,
|
||||
setCategory,
|
||||
setStorageTypes,
|
||||
setSites,
|
||||
loadReferentials,
|
||||
prefill,
|
||||
submit,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Composables d'options des selects du formulaire produit (M6 — ERP-205).
|
||||
*
|
||||
* Chaque referentiel est borne (quelques dizaines d'entrees) : on le recupere en
|
||||
* entier via l'echappatoire `?pagination=false`, avec l'en-tete
|
||||
* `Accept: application/ld+json` impose par API Platform 4 pour obtenir l'enveloppe
|
||||
* Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`), renvoyee telle
|
||||
* quelle dans le payload POST (relations ManyToOne / ManyToMany).
|
||||
*
|
||||
* Etat 100 % local a l'instance (refs) — aucune persistance URL. Chaque appel cree
|
||||
* sa propre instance ; le formulaire en consomme une via `useProductForm`.
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
|
||||
export interface RefOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Membre Hydra minimal commun aux referentiels consommes ici. */
|
||||
interface HydraMember {
|
||||
'@id': string
|
||||
name?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||
|
||||
/**
|
||||
* Recupere une collection complete (pagination desactivee) et la projette en
|
||||
* options `{ value: IRI, label }`. Resilient : l'appelant gere l'echec (liste vide).
|
||||
*/
|
||||
async function fetchOptions(
|
||||
url: string,
|
||||
query: Record<string, string | string[]>,
|
||||
toLabel: (member: HydraMember) => string,
|
||||
): Promise<RefOption[]> {
|
||||
const res = await useApi().get<{ member?: HydraMember[] }>(
|
||||
url,
|
||||
{ pagination: 'false', ...query },
|
||||
{ headers: LD_JSON_HEADERS, toast: false },
|
||||
)
|
||||
return (res.member ?? []).map(m => ({ value: m['@id'], label: toLabel(m) }))
|
||||
}
|
||||
|
||||
/** Sites de disponibilite (libelle = nom du site). */
|
||||
export function useSiteOptions() {
|
||||
const options = ref<RefOption[]>([])
|
||||
|
||||
async function load(): Promise<void> {
|
||||
options.value = await fetchOptions('/sites', {}, s => s.name ?? '')
|
||||
}
|
||||
|
||||
return { options, load }
|
||||
}
|
||||
|
||||
/**
|
||||
* Categories produit. Filtrees au type voulu (`?typeCode=PRODUIT` pour le produit,
|
||||
* RG-6.05) cote serveur — le provider Category supporte deja `typeCode`.
|
||||
*/
|
||||
export function useCategoryOptions(params: { typeCode: string }) {
|
||||
const options = ref<RefOption[]>([])
|
||||
|
||||
async function load(): Promise<void> {
|
||||
options.value = await fetchOptions('/categories', { typeCode: params.typeCode }, c => c.name ?? '')
|
||||
}
|
||||
|
||||
return { options, load }
|
||||
}
|
||||
|
||||
/**
|
||||
* Types de stockage (libelle = `label`). Filtres par les sites selectionnes
|
||||
* (`?siteId[]=…`, RG-6.06) : on ne charge que les types disponibles sur AU MOINS
|
||||
* UN des sites passes. Sans site, la liste est videe (le multi-select depend des
|
||||
* sites).
|
||||
*/
|
||||
export function useStorageTypeOptions() {
|
||||
const options = ref<RefOption[]>([])
|
||||
|
||||
async function load(siteIds: number[]): Promise<void> {
|
||||
if (siteIds.length === 0) {
|
||||
options.value = []
|
||||
return
|
||||
}
|
||||
options.value = await fetchOptions(
|
||||
'/storage_types',
|
||||
{ 'siteId[]': siteIds.map(String) },
|
||||
s => s.label ?? '',
|
||||
)
|
||||
}
|
||||
|
||||
return { options, load }
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h, Suspense } from 'vue'
|
||||
|
||||
// Produit charge simule (cles de la reponse reelle § 4.0.bis).
|
||||
const PRODUCT = {
|
||||
id: 34,
|
||||
code: 'BLE-01',
|
||||
name: 'Blé tendre',
|
||||
states: ['PURCHASE'],
|
||||
manufactured: false,
|
||||
containsMolasses: false,
|
||||
category: { '@id': '/api/categories/12', id: 12, name: 'Céréales', code: 'CEREALES' },
|
||||
sites: [{ '@id': '/api/sites/1', id: 1, name: 'Chatellerault' }],
|
||||
storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }],
|
||||
createdAt: '', updatedAt: '',
|
||||
}
|
||||
|
||||
// Holders crees dans les factories (vue initialise au moment de l'import page).
|
||||
const fx = vi.hoisted(() => ({
|
||||
isSale: null as unknown as { value: boolean },
|
||||
submit: vi.fn(),
|
||||
prefill: vi.fn(),
|
||||
loadReferentials: vi.fn(),
|
||||
load: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('~/modules/catalog/composables/useProductForm', async () => {
|
||||
const { ref, reactive } = await import('vue')
|
||||
fx.isSale = ref(false)
|
||||
return {
|
||||
PRODUCT_STATES: ['PURCHASE', 'SALE', 'OTHER'],
|
||||
useProductForm: () => ({
|
||||
form: reactive({
|
||||
code: null, name: null, states: [], siteIris: [],
|
||||
categoryIri: null, storageTypeIris: [], manufactured: false, containsMolasses: false,
|
||||
}),
|
||||
errors: reactive({}),
|
||||
submitting: ref(false),
|
||||
isSale: fx.isSale,
|
||||
siteOptions: ref([]),
|
||||
categoryOptions: ref([]),
|
||||
storageTypeOptions: ref([]),
|
||||
setStates: vi.fn(),
|
||||
setCategory: vi.fn(),
|
||||
setStorageTypes: vi.fn(),
|
||||
setSites: vi.fn(),
|
||||
loadReferentials: fx.loadReferentials,
|
||||
prefill: fx.prefill,
|
||||
submit: fx.submit,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('~/modules/catalog/composables/useProduct', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useProduct: () => ({
|
||||
product: ref(PRODUCT),
|
||||
loading: ref(false),
|
||||
error: ref(false),
|
||||
load: fx.load,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||
const mockPush = vi.hoisted(() => vi.fn())
|
||||
const mockNavigateTo = vi.hoisted(() => vi.fn())
|
||||
const mockCan = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('useHead', () => undefined)
|
||||
vi.stubGlobal('useRoute', () => ({ params: { id: '34' } }))
|
||||
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||
vi.stubGlobal('navigateTo', mockNavigateTo)
|
||||
|
||||
const EditPage = (await import('../admin/products/[id]/edit.vue')).default
|
||||
|
||||
const ButtonStub = defineComponent({
|
||||
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||
emits: ['click'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||
},
|
||||
})
|
||||
const InputStub = defineComponent({
|
||||
props: { label: { type: String, default: '' }, modelValue: { default: null } },
|
||||
setup(props) { return () => h('input', { 'data-label': props.label }) },
|
||||
})
|
||||
const CheckboxStub = defineComponent({
|
||||
props: { label: { type: String, default: '' } },
|
||||
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
|
||||
})
|
||||
// Placeholder : rendu sans aucun appel API (juste un marqueur).
|
||||
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
|
||||
|
||||
const stubs = {
|
||||
MalioButtonIcon: ButtonStub,
|
||||
MalioButton: ButtonStub,
|
||||
MalioInputText: InputStub,
|
||||
MalioSelect: InputStub,
|
||||
MalioSelectCheckbox: InputStub,
|
||||
MalioCheckbox: CheckboxStub,
|
||||
ProductPlaceholderTabs: TabsStub,
|
||||
}
|
||||
|
||||
async function mountPage() {
|
||||
const wrapper = mount(defineComponent({
|
||||
components: { EditPage },
|
||||
setup: () => () => h(Suspense, null, { default: () => h(EditPage) }),
|
||||
}), { global: { stubs } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('Écran Modifier un produit (page /admin/products/{id}/edit)', () => {
|
||||
beforeEach(() => {
|
||||
fx.submit.mockReset().mockResolvedValue(true)
|
||||
fx.prefill.mockReset().mockResolvedValue(undefined)
|
||||
fx.loadReferentials.mockReset().mockResolvedValue(undefined)
|
||||
fx.load.mockReset().mockResolvedValue(undefined)
|
||||
mockPush.mockReset()
|
||||
mockNavigateTo.mockReset()
|
||||
mockCan.mockReset().mockReturnValue(true)
|
||||
fx.isSale.value = false
|
||||
})
|
||||
|
||||
it('charge le produit et pre-remplit le formulaire au montage', async () => {
|
||||
await mountPage()
|
||||
expect(fx.load).toHaveBeenCalled()
|
||||
expect(fx.prefill).toHaveBeenCalledWith(PRODUCT)
|
||||
})
|
||||
|
||||
it('redirige vers la liste sans la permission manage', async () => {
|
||||
mockCan.mockReturnValue(false)
|
||||
await mountPage()
|
||||
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products')
|
||||
})
|
||||
|
||||
it('bouton « Enregistrer » : submit (PATCH) puis retour a la liste', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.find('[data-label="admin.products.edit.save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(fx.submit).toHaveBeenCalled()
|
||||
expect(mockPush).toHaveBeenCalledWith('/admin/products')
|
||||
})
|
||||
|
||||
it('affiche les onglets placeholder (rendu sans appel API)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
expect(wrapper.find('[data-testid="placeholder-tabs"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h, Suspense } from 'vue'
|
||||
|
||||
// ── Mock du composable form (sa logique est testee a part : useProductForm.spec).
|
||||
// Ici on teste le WIRING de la page : rendu conditionnel RG-6.03 + submit→redirect.
|
||||
// Les refs partagees sont creees DANS la factory (vue est initialise au moment ou
|
||||
// la page est importee) et exposees via un holder hoiste pour pilotage par test.
|
||||
const fx = vi.hoisted(() => ({
|
||||
isSale: null as unknown as { value: boolean },
|
||||
submit: vi.fn(),
|
||||
loadReferentials: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('~/modules/catalog/composables/useProductForm', async () => {
|
||||
const { ref, reactive } = await import('vue')
|
||||
fx.isSale = ref(false)
|
||||
return {
|
||||
PRODUCT_STATES: ['PURCHASE', 'SALE', 'OTHER'],
|
||||
useProductForm: () => ({
|
||||
form: reactive({
|
||||
code: null, name: null, states: [], siteIris: [],
|
||||
categoryIri: null, storageTypeIris: [], manufactured: false, containsMolasses: false,
|
||||
}),
|
||||
errors: reactive({}),
|
||||
submitting: ref(false),
|
||||
isSale: fx.isSale,
|
||||
siteOptions: ref([]),
|
||||
categoryOptions: ref([]),
|
||||
storageTypeOptions: ref([]),
|
||||
setStates: vi.fn(),
|
||||
setCategory: vi.fn(),
|
||||
setStorageTypes: vi.fn(),
|
||||
setSites: vi.fn(),
|
||||
loadReferentials: fx.loadReferentials,
|
||||
submit: fx.submit,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||
const mockPush = vi.hoisted(() => vi.fn())
|
||||
const mockNavigateTo = vi.hoisted(() => vi.fn())
|
||||
const mockCan = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('useHead', () => undefined)
|
||||
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||
vi.stubGlobal('navigateTo', mockNavigateTo)
|
||||
|
||||
const NewPage = (await import('../admin/products/new.vue')).default
|
||||
|
||||
const ButtonStub = defineComponent({
|
||||
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||
emits: ['click'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||
},
|
||||
})
|
||||
const InputStub = defineComponent({
|
||||
props: { label: { type: String, default: '' }, modelValue: { default: null } },
|
||||
setup(props) { return () => h('input', { 'data-label': props.label }) },
|
||||
})
|
||||
const CheckboxStub = defineComponent({
|
||||
props: { label: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
|
||||
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
|
||||
})
|
||||
|
||||
// Placeholder (onglets Fournisseurs/Clients) : marqueur sans appel API.
|
||||
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
|
||||
|
||||
const stubs = {
|
||||
MalioButtonIcon: ButtonStub,
|
||||
MalioButton: ButtonStub,
|
||||
MalioInputText: InputStub,
|
||||
MalioSelect: InputStub,
|
||||
MalioSelectCheckbox: InputStub,
|
||||
MalioCheckbox: CheckboxStub,
|
||||
ProductPlaceholderTabs: TabsStub,
|
||||
}
|
||||
|
||||
async function mountPage() {
|
||||
const wrapper = mount(defineComponent({
|
||||
components: { NewPage },
|
||||
setup: () => () => h(Suspense, null, { default: () => h(NewPage) }),
|
||||
}), { global: { stubs } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('Écran Ajouter un produit (page /admin/products/new)', () => {
|
||||
beforeEach(() => {
|
||||
fx.submit.mockReset().mockResolvedValue(true)
|
||||
fx.loadReferentials.mockReset().mockResolvedValue(undefined)
|
||||
mockPush.mockReset()
|
||||
mockNavigateTo.mockReset()
|
||||
mockCan.mockReset().mockReturnValue(true)
|
||||
fx.isSale.value = false
|
||||
})
|
||||
|
||||
it('redirige vers la liste sans la permission manage', async () => {
|
||||
mockCan.mockReturnValue(false)
|
||||
await mountPage()
|
||||
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products')
|
||||
})
|
||||
|
||||
it('charge les referentiels au montage', async () => {
|
||||
await mountPage()
|
||||
expect(fx.loadReferentials).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('RG-6.03 : masque Fabriqué / mélasse tant que l\'etat ne contient pas « Vendu »', async () => {
|
||||
fx.isSale.value = false
|
||||
const wrapper = await mountPage()
|
||||
expect(wrapper.find('[data-label="admin.products.form.manufactured"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-label="admin.products.form.containsMolasses"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('RG-6.03 : affiche Fabriqué / mélasse quand l\'etat contient « Vendu »', async () => {
|
||||
fx.isSale.value = true
|
||||
const wrapper = await mountPage()
|
||||
expect(wrapper.find('[data-label="admin.products.form.manufactured"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-label="admin.products.form.containsMolasses"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('« Valider » : submit puis retour a la liste au succes', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.find('[data-label="admin.products.form.submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(fx.submit).toHaveBeenCalled()
|
||||
expect(mockPush).toHaveBeenCalledWith('/admin/products')
|
||||
})
|
||||
|
||||
it('ne redirige pas si submit echoue (erreurs inline)', async () => {
|
||||
fx.submit.mockResolvedValueOnce(false)
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.find('[data-label="admin.products.form.submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,272 @@
|
||||
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→M5.
|
||||
const mockPush = vi.hoisted(() => vi.fn())
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
const mockCan = vi.hoisted(() => vi.fn())
|
||||
const mockSetFilters = vi.hoisted(() => vi.fn())
|
||||
const mockFetch = vi.hoisted(() => vi.fn())
|
||||
const mockToastError = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('useHead', () => undefined)
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
|
||||
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||
// usePaginatedList est l'auto-import pilotant la liste : on controle items +
|
||||
// setFilters + fetch. La ligne reproduit le contrat JSON reel (§ 4.0.bis).
|
||||
vi.stubGlobal('usePaginatedList', () => ({
|
||||
items: ref<Array<Record<string, unknown>>>([
|
||||
{
|
||||
id: 34,
|
||||
code: 'BLE-TENDRE-01',
|
||||
name: 'Blé tendre',
|
||||
states: ['PURCHASE', 'SALE'],
|
||||
manufactured: true,
|
||||
containsMolasses: true,
|
||||
category: { id: 12, name: 'Céréales', code: 'CEREALES' },
|
||||
sites: [{ id: 1, name: 'Chatellerault', code: '86' }],
|
||||
storageTypes: [{ id: 9, code: 'TAS', label: 'Tas' }],
|
||||
},
|
||||
]),
|
||||
totalItems: ref(1),
|
||||
currentPage: ref(1),
|
||||
itemsPerPage: ref(10),
|
||||
itemsPerPageOptions: ref([10, 25, 50]),
|
||||
fetch: mockFetch,
|
||||
goToPage: vi.fn(),
|
||||
setItemsPerPage: vi.fn(),
|
||||
setFilters: mockSetFilters,
|
||||
}))
|
||||
|
||||
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
|
||||
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
|
||||
globalThis.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
|
||||
const ProductsIndex = (await import('../admin/products/index.vue')).default
|
||||
|
||||
// ── Stubs de composants ──────────────────────────────────────────────────────
|
||||
const ButtonStub = defineComponent({
|
||||
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||
emits: ['click'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||
},
|
||||
})
|
||||
|
||||
const DataTableStub = defineComponent({
|
||||
props: { items: { type: Array, default: () => [] } },
|
||||
emits: ['row-click', 'update:page', 'update:per-page'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('div', { 'data-testid': 'datatable' },
|
||||
(props.items as Array<Record<string, unknown>>).map(it =>
|
||||
h('tr', {
|
||||
'data-row-id': it.id,
|
||||
'data-name': it.name,
|
||||
'data-code': it.code,
|
||||
'data-category': it.categoryName,
|
||||
'onClick': () => emit('row-click', it),
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const DrawerStub = defineComponent({
|
||||
props: { modelValue: { type: Boolean, default: false } },
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()])
|
||||
},
|
||||
})
|
||||
|
||||
const SlotStub = defineComponent({ setup(_, { slots }) { return () => h('div', {}, slots.default?.()) } })
|
||||
|
||||
const PageHeaderStub = defineComponent({
|
||||
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
|
||||
})
|
||||
|
||||
const CheckboxStub = defineComponent({
|
||||
props: { id: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
|
||||
emits: ['update:model-value'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('input', {
|
||||
'type': 'checkbox',
|
||||
'data-id': props.id,
|
||||
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLInputElement).checked),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const SelectStub = defineComponent({
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null] as unknown as () => string | number | null, default: null },
|
||||
options: { type: Array, default: () => [] },
|
||||
emptyOptionLabel: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:model-value'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('select', {
|
||||
'data-empty-label': props.emptyOptionLabel,
|
||||
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLSelectElement).value),
|
||||
}, (props.options as Array<{ value: string | number, label: string }>).map(o =>
|
||||
h('option', { value: o.value }, o.label),
|
||||
))
|
||||
},
|
||||
})
|
||||
|
||||
const InputTextStub = defineComponent({ setup() { return () => h('input') } })
|
||||
|
||||
function mountPage() {
|
||||
return mount(ProductsIndex, {
|
||||
global: {
|
||||
stubs: {
|
||||
PageHeader: PageHeaderStub,
|
||||
MalioButton: ButtonStub,
|
||||
MalioDataTable: DataTableStub,
|
||||
MalioDrawer: DrawerStub,
|
||||
MalioAccordion: SlotStub,
|
||||
MalioAccordionItem: SlotStub,
|
||||
MalioInputText: InputTextStub,
|
||||
MalioSelect: SelectStub,
|
||||
MalioCheckbox: CheckboxStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('Catalogue produit (page /admin/products)', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockReset()
|
||||
mockApiGet.mockReset().mockImplementation((url: string) => {
|
||||
if (url === '/categories') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/categories/12', id: 12, name: 'Céréales' }] })
|
||||
}
|
||||
if (url === '/sites') {
|
||||
return Promise.resolve({ member: [{ id: 1, name: 'Chatellerault' }] })
|
||||
}
|
||||
return Promise.resolve({ member: [] })
|
||||
})
|
||||
mockCan.mockReset().mockReturnValue(true)
|
||||
mockSetFilters.mockReset()
|
||||
mockFetch.mockReset()
|
||||
mockToastError.mockReset()
|
||||
})
|
||||
|
||||
it('charge la liste au montage', async () => {
|
||||
mountPage()
|
||||
await flushPromises()
|
||||
expect(mockFetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mappe les colonnes Nom / Numéro / Catégorie sur le JSON réel (§ 4.0.bis)', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
const row = wrapper.find('tr[data-row-id="34"]')
|
||||
expect(row.attributes('data-name')).toBe('Blé tendre')
|
||||
expect(row.attributes('data-code')).toBe('BLE-TENDRE-01')
|
||||
expect(row.attributes('data-category')).toBe('Céréales')
|
||||
})
|
||||
|
||||
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
|
||||
mockCan.mockImplementation((perm: string) => perm === 'catalog.products.manage')
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-label="admin.products.add"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
|
||||
mockCan.mockImplementation((perm: string) => perm === 'catalog.products.view')
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-label="admin.products.add"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('navigue vers l\'édition au clic sur une ligne', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
await wrapper.find('tr[data-row-id="34"]').trigger('click')
|
||||
expect(mockPush).toHaveBeenCalledWith('/admin/products/34/edit')
|
||||
})
|
||||
|
||||
it('navigue vers la création au clic sur « + Ajouter »', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-label="admin.products.add"]').trigger('click')
|
||||
expect(mockPush).toHaveBeenCalledWith('/admin/products/new')
|
||||
})
|
||||
|
||||
it('appelle l\'export XLSX sur /products/export.xlsx en blob', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-label="admin.products.export"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
'/products/export.xlsx',
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ responseType: 'blob', toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('répercute les sites cochés dans setFilters (filtre multi, clé siteId[])', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('input[data-id="filter-site-1"]').setValue(true)
|
||||
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
|
||||
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||
{ 'siteId[]': ['1'] },
|
||||
{ replace: true },
|
||||
)
|
||||
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('répercute l\'état sélectionné dans setFilters (param state)', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('select[data-empty-label="admin.products.filters.stateAll"]').setValue('SALE')
|
||||
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
|
||||
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||
{ state: 'SALE' },
|
||||
{ replace: true },
|
||||
)
|
||||
})
|
||||
|
||||
it('répercute la catégorie sélectionnée dans setFilters (param categoryId)', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('select[data-empty-label="admin.products.filters.categoryAll"]').setValue('12')
|
||||
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
|
||||
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||
{ categoryId: '12' },
|
||||
{ replace: true },
|
||||
)
|
||||
})
|
||||
|
||||
it('badge filtres actifs + Réinitialiser vide l\'état appliqué', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('input[data-id="filter-site-1"]').setValue(true)
|
||||
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
|
||||
|
||||
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
|
||||
expect(wrapper.find('[data-label="admin.products.filters.title (1)"]').exists()).toBe(true)
|
||||
|
||||
// Réinitialiser → query propre (setFilters avec objet vide).
|
||||
await wrapper.find('[data-label="admin.products.filters.reset"]').trigger('click')
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true })
|
||||
})
|
||||
})
|
||||
@@ -3,13 +3,28 @@
|
||||
<PageHeader>
|
||||
{{ t('admin.categories.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
:label="t('admin.categories.newCategory')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
<!-- gap-8 = 32px d'espacement entre Filtres et Ajouter (meme
|
||||
design que le Repertoire Clients). -->
|
||||
<div class="flex items-center gap-8">
|
||||
<!-- Bouton Filtres a GAUCHE d'Ajouter. Le compteur reflete
|
||||
les filtres actifs. -->
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="filterButtonLabel"
|
||||
icon-name="mdi:tune"
|
||||
icon-position="left"
|
||||
icon-size="24"
|
||||
@click="openFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
variant="secondary"
|
||||
:label="t('admin.categories.newCategory')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
@@ -47,6 +62,60 @@
|
||||
:loading="deleting"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
|
||||
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
|
||||
« Appliquer ». Meme pattern que le Repertoire Clients. Etat 100 %
|
||||
local, jamais dans l'URL (regle ABSOLUE n°6). -->
|
||||
<MalioDrawer
|
||||
v-model="filterDrawerOpen"
|
||||
drawer-class="max-w-[450px]"
|
||||
body-class="p-0"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold uppercase">{{ t('admin.categories.filters.title') }}</h2>
|
||||
</template>
|
||||
|
||||
<MalioAccordion>
|
||||
<!-- Recherche par nom (param `name`, partiel insensible a la casse). -->
|
||||
<MalioAccordionItem :title="t('admin.categories.filters.search')" value="search">
|
||||
<MalioInputText
|
||||
v-model="draftSearch"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Type(s) : cases a cocher (multi). Une categorie remonte si
|
||||
elle porte AU MOINS UN des types coches (OR cote back). -->
|
||||
<MalioAccordionItem :title="t('admin.categories.filters.types')" value="types">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in typeFilterOptions"
|
||||
:id="`filter-type-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftTypeIds.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleType(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('admin.categories.filters.reset')"
|
||||
button-class="w-m-btn-action"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('admin.categories.filters.apply')"
|
||||
button-class="w-[170px]"
|
||||
@click="applyFilters"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -55,7 +124,7 @@ import type { Category } from '~/modules/catalog/types/category'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { can } = usePermissions()
|
||||
const { fetchTypes } = useCategoriesAdmin()
|
||||
const { types, fetchTypes } = useCategoriesAdmin()
|
||||
const { submitDelete } = useCategoryForm()
|
||||
|
||||
useHead({ title: t('admin.categories.title') })
|
||||
@@ -74,6 +143,7 @@ const {
|
||||
fetch: fetchCategories,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
setFilters,
|
||||
} = usePaginatedList<Category>({ url: '/categories' })
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
@@ -82,21 +152,96 @@ const deleteModalOpen = ref(false)
|
||||
const categoryToDelete = ref<Category | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
// Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4) —
|
||||
// on aplatit en label lisible pour l'affichage.
|
||||
// Colonnes du datatable. Les types sont embarques cote API (ManyToMany) — on
|
||||
// aplatit en libelles joints par une virgule pour l'affichage.
|
||||
const columns = [
|
||||
{ key: 'name', label: t('admin.categories.table.name') },
|
||||
{ key: 'typeLabel', label: t('admin.categories.table.type') },
|
||||
{ key: 'typesLabel', label: t('admin.categories.table.types') },
|
||||
]
|
||||
|
||||
const categoryItems = computed(() =>
|
||||
categories.value.map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
typeLabel: cat.categoryType?.label ?? '',
|
||||
typesLabel: (cat.categoryTypes ?? []).map(ct => ct.label).join(', '),
|
||||
})),
|
||||
)
|
||||
|
||||
// ── Filtres (drawer) ────────────────────────────────────────────────────────
|
||||
// Deux niveaux d'etat (pattern Repertoire Clients) :
|
||||
// - APPLIED : pilote la liste + le compteur du bouton. Modifie uniquement au
|
||||
// clic « Appliquer » / « Réinitialiser ».
|
||||
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
|
||||
const filterDrawerOpen = ref(false)
|
||||
|
||||
const draftSearch = ref('')
|
||||
const draftTypeIds = ref<number[]>([])
|
||||
|
||||
const appliedSearch = ref('')
|
||||
const appliedTypeIds = ref<number[]>([])
|
||||
|
||||
// Options du filtre Type(s), derivees du referentiel deja charge (fetchTypes).
|
||||
const typeFilterOptions = computed(() =>
|
||||
types.value.map(ct => ({ value: ct.id, label: ct.label })),
|
||||
)
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
let count = 0
|
||||
if (appliedSearch.value.trim() !== '') count++
|
||||
if (appliedTypeIds.value.length > 0) count++
|
||||
return count
|
||||
})
|
||||
|
||||
const filterButtonLabel = computed(() => {
|
||||
const base = t('admin.categories.filters.title')
|
||||
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
|
||||
})
|
||||
|
||||
// Recopie l'etat applique vers le brouillon puis ouvre le drawer.
|
||||
function openFilters(): void {
|
||||
draftSearch.value = appliedSearch.value
|
||||
draftTypeIds.value = [...appliedTypeIds.value]
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function toggleType(id: number, selected: boolean): void {
|
||||
draftTypeIds.value = selected
|
||||
? [...draftTypeIds.value, id]
|
||||
: draftTypeIds.value.filter(t => t !== id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le payload de filtres serveur a partir de l'etat applique. Cle
|
||||
* `typeId[]` pour que PHP la parse en tableau (OR cote back). Filtres vides omis.
|
||||
*/
|
||||
function buildFilterPayload(): Record<string, string | string[]> {
|
||||
const payload: Record<string, string | string[]> = {}
|
||||
if (appliedSearch.value.trim() !== '') payload.name = appliedSearch.value.trim()
|
||||
if (appliedTypeIds.value.length > 0) payload['typeId[]'] = appliedTypeIds.value.map(String)
|
||||
return payload
|
||||
}
|
||||
|
||||
// « Appliquer » : recopie brouillon → applied, pousse les filtres (retombe en
|
||||
// page 1 via usePaginatedList) et ferme le drawer.
|
||||
function applyFilters(): void {
|
||||
appliedSearch.value = draftSearch.value.trim()
|
||||
appliedTypeIds.value = [...draftTypeIds.value]
|
||||
|
||||
setFilters(buildFilterPayload(), { replace: true })
|
||||
filterDrawerOpen.value = false
|
||||
}
|
||||
|
||||
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
|
||||
// Le drawer reste ouvert pour montrer le formulaire vide.
|
||||
function resetFilters(): void {
|
||||
draftSearch.value = ''
|
||||
draftTypeIds.value = []
|
||||
appliedSearch.value = ''
|
||||
appliedTypeIds.value = []
|
||||
|
||||
setFilters({}, { replace: true })
|
||||
}
|
||||
|
||||
function getCategoryById(id: number): Category | undefined {
|
||||
return categories.value.find(c => c.id === id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete : retour vers le catalogue + nom du produit. -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('admin.products.edit.back')"
|
||||
v-bind="{ ariaLabel: t('admin.products.edit.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Etats de chargement / introuvable. -->
|
||||
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('admin.products.edit.loading') }}</p>
|
||||
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('admin.products.edit.notFound') }}</p>
|
||||
|
||||
<template v-else-if="product">
|
||||
<!-- ── Formulaire principal pre-rempli (mêmes champs/regles que l'ajout,
|
||||
RG-6.01→6.07). Bouton « Enregistrer » → PATCH (RG-6.08). -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Etat du produit : multi-select obligatoire (>= 1, RG-6.02). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="form.states"
|
||||
:options="stateOptions"
|
||||
:label="t('admin.products.form.states')"
|
||||
:display-tag="true"
|
||||
:required="true"
|
||||
:error="errors.states"
|
||||
@update:model-value="(v: (string | number)[]) => setStates(v.map(String))"
|
||||
/>
|
||||
<!-- Sites de disponibilite : multi-select obligatoire (>= 1, RG-6.04).
|
||||
Pilote la cascade Type de stockage (RG-6.06). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="form.siteIris"
|
||||
:options="siteOptions"
|
||||
:label="t('admin.products.form.sites')"
|
||||
:display-tag="true"
|
||||
:required="true"
|
||||
:error="errors.sites"
|
||||
@update:model-value="(v: (string | number)[]) => setSites(v.map(String))"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:label="t('admin.products.form.name')"
|
||||
:required="true"
|
||||
:error="errors.name"
|
||||
/>
|
||||
<!-- Code modifiable techniquement ; l'unicite reste re-validee serveur (RG-6.01). -->
|
||||
<MalioInputText
|
||||
v-model="form.code"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('admin.products.form.code')"
|
||||
:required="true"
|
||||
:error="errors.code"
|
||||
/>
|
||||
<!-- Categorie produit : select simple obligatoire, filtre type PRODUIT (RG-6.05). -->
|
||||
<MalioSelect
|
||||
:model-value="form.categoryIri"
|
||||
:options="categoryOptions"
|
||||
:label="t('admin.products.form.category')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="errors.category"
|
||||
@update:model-value="(v: string | number | null) => setCategory(v === null || v === '' ? null : String(v))"
|
||||
/>
|
||||
<!-- Type de stockage : multi-select obligatoire (>= 1), options filtrees
|
||||
par les sites selectionnes (RG-6.06). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="form.storageTypeIris"
|
||||
:options="storageTypeOptions"
|
||||
:label="t('admin.products.form.storageTypes')"
|
||||
:display-tag="true"
|
||||
:required="true"
|
||||
:error="errors.storageTypes"
|
||||
@update:model-value="(v: (string | number)[]) => setStorageTypes(v.map(String))"
|
||||
/>
|
||||
<!-- RG-6.03 : « Fabriqué » + « Contient de la mélasse » visibles
|
||||
uniquement si l'Etat contient « Vendu ». -->
|
||||
<MalioCheckbox
|
||||
v-if="isSale"
|
||||
v-model="form.manufactured"
|
||||
:label="t('admin.products.form.manufactured')"
|
||||
group-class="self-center"
|
||||
/>
|
||||
<MalioCheckbox
|
||||
v-if="isSale"
|
||||
v-model="form.containsMolasses"
|
||||
:label="t('admin.products.form.containsMolasses')"
|
||||
group-class="self-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('admin.products.edit.save')"
|
||||
:disabled="submitting"
|
||||
@click="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Onglets Fournisseurs / Clients en placeholder (HP-M6-01). -->
|
||||
<ProductPlaceholderTabs />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useProductForm, PRODUCT_STATES } from '~/modules/catalog/composables/useProductForm'
|
||||
import { useProduct } from '~/modules/catalog/composables/useProduct'
|
||||
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { can } = usePermissions()
|
||||
|
||||
// Gating de la route : la modification est reservee a `manage` (catalogue admin-only).
|
||||
if (!can('catalog.products.manage')) {
|
||||
await navigateTo('/admin/products')
|
||||
}
|
||||
|
||||
const productId = route.params.id as string
|
||||
|
||||
const { product, loading, error, load } = useProduct(productId)
|
||||
|
||||
const {
|
||||
form,
|
||||
errors,
|
||||
submitting,
|
||||
isSale,
|
||||
siteOptions,
|
||||
categoryOptions,
|
||||
storageTypeOptions,
|
||||
setStates,
|
||||
setCategory,
|
||||
setStorageTypes,
|
||||
setSites,
|
||||
loadReferentials,
|
||||
prefill,
|
||||
submit,
|
||||
} = useProductForm()
|
||||
|
||||
const headerTitle = computed(() => product.value?.name ?? t('admin.products.edit.title'))
|
||||
|
||||
useHead({ title: headerTitle })
|
||||
|
||||
// Options de l'etat : libelles i18n (la valeur d'option = code enum).
|
||||
const stateOptions = computed(() =>
|
||||
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
|
||||
)
|
||||
|
||||
/** Retour vers le catalogue produit (fleche d'en-tete). */
|
||||
function goBack(): void {
|
||||
router.push('/admin/products')
|
||||
}
|
||||
|
||||
/** Soumet la modification (PATCH) ; au succes, retour a la liste. */
|
||||
async function onSubmit(): Promise<void> {
|
||||
const ok = await submit()
|
||||
if (ok) {
|
||||
router.push('/admin/products')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Referentiels (selects) + detail du produit charges en parallele.
|
||||
await Promise.all([
|
||||
loadReferentials().catch(() => {}),
|
||||
load(),
|
||||
])
|
||||
// Pre-remplissage une fois le produit charge (echec de chargement => message).
|
||||
if (product.value) {
|
||||
await prefill(product.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('admin.products.title') }}
|
||||
<template #actions>
|
||||
<!-- gap-8 = 32px d'espacement entre Filtrer et Ajouter (meme
|
||||
design que le Repertoire transporteurs / la Gestion des categories). -->
|
||||
<div class="flex items-center gap-8">
|
||||
<!-- Bouton Filtrer a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="tertiary"
|
||||
:label="filterButtonLabel"
|
||||
icon-name="mdi:tune"
|
||||
icon-position="left"
|
||||
icon-size="24"
|
||||
@click="openFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
variant="secondary"
|
||||
:label="t('admin.products.add')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="goToCreate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Datatable branchee sur usePaginatedList : pagination serveur, tri
|
||||
name ASC par defaut (cote back, § 4.1). Colonnes Nom / Numero /
|
||||
Categorie (docx p.3). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
row-clickable
|
||||
:empty-message="t('admin.products.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('admin.products.export')"
|
||||
:disabled="exporting"
|
||||
@click="exportXlsx"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
|
||||
« Voir les résultats ». Meme pattern que les repertoires M1→M5.
|
||||
Etat 100 % local, jamais dans l'URL (regle ABSOLUE n°6). -->
|
||||
<MalioDrawer
|
||||
v-model="filterDrawerOpen"
|
||||
drawer-class="max-w-[450px]"
|
||||
body-class="p-0"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold uppercase">{{ t('admin.products.filters.title') }}</h2>
|
||||
</template>
|
||||
|
||||
<MalioAccordion>
|
||||
<!-- Recherche : code + nom (param `search`, partiel insensible a la casse). -->
|
||||
<MalioAccordionItem :title="t('admin.products.filters.search')" value="search">
|
||||
<MalioInputText
|
||||
v-model="draftSearch"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Categorie : select simple (param `categoryId`). Referentiel borne
|
||||
aux categories de type PRODUIT (RG-6.05). -->
|
||||
<MalioAccordionItem :title="t('admin.products.filters.category')" value="category">
|
||||
<MalioSelect
|
||||
:model-value="draftCategoryId"
|
||||
:options="categoryOptions"
|
||||
:empty-option-label="t('admin.products.filters.categoryAll')"
|
||||
@update:model-value="(v: string | number | null) => draftCategoryId = v === null || v === '' ? null : Number(v)"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Etat : select simple (param `state`, enum PURCHASE / SALE / OTHER). -->
|
||||
<MalioAccordionItem :title="t('admin.products.filters.state')" value="state">
|
||||
<MalioSelect
|
||||
:model-value="draftState"
|
||||
:options="stateOptions"
|
||||
:empty-option-label="t('admin.products.filters.stateAll')"
|
||||
@update:model-value="(v: string | number | null) => draftState = v === null || v === '' ? null : String(v)"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Site(s) : cases a cocher (multi, param `siteId[]`). Un produit
|
||||
remonte s'il est disponible sur AU MOINS UN des sites coches. -->
|
||||
<MalioAccordionItem :title="t('admin.products.filters.site')" value="site">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in siteOptions"
|
||||
:id="`filter-site-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftSiteIds.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleSite(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('admin.products.filters.reset')"
|
||||
button-class="w-m-btn-action"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('admin.products.filters.apply')"
|
||||
button-class="w-[170px]"
|
||||
@click="applyFilters"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { Product } from '~/modules/catalog/types/product'
|
||||
|
||||
interface FilterOption {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('admin.products.title') })
|
||||
|
||||
// Catalogue produit admin-only (docx p.3) : « + Ajouter » reserve a `manage`.
|
||||
// « Filtrer » / « Exporter » suivent `view` (gate page-level). L'item sidebar
|
||||
// est deja masque cote back pour les roles sans `view` (RBAC § 5.2).
|
||||
const canManage = computed(() => can('catalog.products.manage'))
|
||||
const canView = computed(() => can('catalog.products.view'))
|
||||
|
||||
// Pagination serveur via le composable partage. Le ProductProvider applique
|
||||
// deja name ASC (§ 4.1) — pas de defaultSort cote front tant qu'aucun
|
||||
// OrderFilter n'est expose.
|
||||
const {
|
||||
items: products,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadProducts,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
setFilters,
|
||||
} = usePaginatedList<Product>({ url: '/products' })
|
||||
|
||||
// Mappe les produits en objets « plats » pour MalioDataTable (items typees
|
||||
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
|
||||
// implicite, contrairement a l'interface Product. Meme pattern que M1→M5.
|
||||
const rows = computed(() => products.value.map(product => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
code: product.code,
|
||||
categoryName: product.category?.name ?? '',
|
||||
})))
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: t('admin.products.column.name') },
|
||||
{ key: 'code', label: t('admin.products.column.code') },
|
||||
{ key: 'categoryName', label: t('admin.products.column.category') },
|
||||
]
|
||||
|
||||
/** Clic sur une ligne → ecran d'edition (route imbriquee /admin/products/{id}/edit). */
|
||||
function onRowClick(item: Record<string, unknown>): void {
|
||||
router.push(`/admin/products/${item.id}/edit`)
|
||||
}
|
||||
|
||||
function goToCreate(): void {
|
||||
router.push('/admin/products/new')
|
||||
}
|
||||
|
||||
// ── Referentiels des filtres ─────────────────────────────────────────────────
|
||||
// Charges une fois (pagination desactivee, referentiels bornes). Categories
|
||||
// filtrees au type PRODUIT (RG-6.05) ; sites = tous les sites actifs.
|
||||
const categoryOptions = ref<FilterOption[]>([])
|
||||
const siteOptions = ref<FilterOption[]>([])
|
||||
|
||||
// Etats produit (miroir de l'enum back Product::STATE_*). Le libelle est resolu
|
||||
// par i18n. Select simple cote filtre (`?state=` n'accepte qu'une valeur).
|
||||
const PRODUCT_STATES = ['PURCHASE', 'SALE', 'OTHER'] as const
|
||||
|
||||
const stateOptions = computed(() =>
|
||||
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
|
||||
)
|
||||
|
||||
interface HydraMember { '@id': string, id: number, name?: string, postalCode?: string }
|
||||
|
||||
/** Recupere une collection complete (pagination desactivee) en Hydra. */
|
||||
async function fetchAll<T extends HydraMember>(
|
||||
url: string,
|
||||
query: Record<string, string> = {},
|
||||
): Promise<T[]> {
|
||||
const res = await api.get<{ member?: T[] }>(
|
||||
url,
|
||||
{ pagination: 'false', ...query },
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
return res.member ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge les referentiels des filtres en parallele et de maniere resiliente :
|
||||
* un referentiel en echec (403/500) reste vide sans casser l'autre.
|
||||
*/
|
||||
async function loadFilterReferentials(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
fetchAll('/categories', { typeCode: 'PRODUIT' })
|
||||
.then((cats) => { categoryOptions.value = cats.map(c => ({ value: c.id, label: c.name ?? '' })) }),
|
||||
fetchAll('/sites')
|
||||
.then((sitesList) => { siteOptions.value = sitesList.map(s => ({ value: s.id, label: s.name ?? '' })) }),
|
||||
])
|
||||
}
|
||||
|
||||
// ── Filtres (drawer) ─────────────────────────────────────────────────────────
|
||||
// Deux niveaux d'etat (pattern repertoires M1→M5) :
|
||||
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
|
||||
// uniquement au clic « Voir les résultats » / « Réinitialiser ».
|
||||
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
|
||||
const filterDrawerOpen = ref(false)
|
||||
|
||||
const draftSearch = ref('')
|
||||
const draftCategoryId = ref<number | null>(null)
|
||||
const draftState = ref<string | null>(null)
|
||||
const draftSiteIds = ref<number[]>([])
|
||||
|
||||
const appliedSearch = ref('')
|
||||
const appliedCategoryId = ref<number | null>(null)
|
||||
const appliedState = ref<string | null>(null)
|
||||
const appliedSiteIds = ref<number[]>([])
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
let count = 0
|
||||
if (appliedSearch.value.trim() !== '') count++
|
||||
if (appliedCategoryId.value !== null) count++
|
||||
if (appliedState.value !== null) count++
|
||||
if (appliedSiteIds.value.length > 0) count++
|
||||
return count
|
||||
})
|
||||
|
||||
const filterButtonLabel = computed(() => {
|
||||
const base = t('admin.products.filters.title')
|
||||
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
|
||||
})
|
||||
|
||||
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la reouverture
|
||||
// reflete les filtres actifs.
|
||||
function openFilters(): void {
|
||||
draftSearch.value = appliedSearch.value
|
||||
draftCategoryId.value = appliedCategoryId.value
|
||||
draftState.value = appliedState.value
|
||||
draftSiteIds.value = [...appliedSiteIds.value]
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
/** Coche / decoche un site dans le brouillon (filtre multi). */
|
||||
function toggleSite(id: number, selected: boolean): void {
|
||||
draftSiteIds.value = selected
|
||||
? [...draftSiteIds.value, id]
|
||||
: draftSiteIds.value.filter(s => s !== id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le payload de filtres serveur a partir de l'etat applique. Cle
|
||||
* `siteId[]` pour que PHP la parse en tableau (OR cote back). Les filtres vides
|
||||
* sont omis pour une query propre.
|
||||
*/
|
||||
function buildFilterPayload(): Record<string, string | string[]> {
|
||||
const payload: Record<string, string | string[]> = {}
|
||||
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
||||
if (appliedCategoryId.value !== null) payload.categoryId = String(appliedCategoryId.value)
|
||||
if (appliedState.value !== null) payload.state = appliedState.value
|
||||
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = appliedSiteIds.value.map(String)
|
||||
return payload
|
||||
}
|
||||
|
||||
// « Voir les résultats » : recopie brouillon → applied, pousse les filtres
|
||||
// (retombe en page 1 via usePaginatedList) et ferme le drawer.
|
||||
function applyFilters(): void {
|
||||
appliedSearch.value = draftSearch.value.trim()
|
||||
appliedCategoryId.value = draftCategoryId.value
|
||||
appliedState.value = draftState.value
|
||||
appliedSiteIds.value = [...draftSiteIds.value]
|
||||
|
||||
setFilters(buildFilterPayload(), { replace: true })
|
||||
filterDrawerOpen.value = false
|
||||
}
|
||||
|
||||
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
|
||||
// Le drawer reste ouvert pour montrer le formulaire vide.
|
||||
function resetFilters(): void {
|
||||
draftSearch.value = ''
|
||||
draftCategoryId.value = null
|
||||
draftState.value = null
|
||||
draftSiteIds.value = []
|
||||
|
||||
appliedSearch.value = ''
|
||||
appliedCategoryId.value = null
|
||||
appliedState.value = null
|
||||
appliedSiteIds.value = []
|
||||
|
||||
setFilters({}, { replace: true })
|
||||
}
|
||||
|
||||
// ── Export XLSX ──────────────────────────────────────────────────────────────
|
||||
// Memes filtres que la vue : l'export reflete exactement ce que l'utilisateur voit.
|
||||
const exporting = ref(false)
|
||||
|
||||
async function exportXlsx(): Promise<void> {
|
||||
if (exporting.value) {
|
||||
return
|
||||
}
|
||||
exporting.value = true
|
||||
try {
|
||||
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
|
||||
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
|
||||
// contenu faute d'overload blob sur le client partage (meme pattern M2→M5).
|
||||
const blob = await api.get<Blob>('/products/export.xlsx', buildFilterPayload(), {
|
||||
responseType: 'blob',
|
||||
toast: false,
|
||||
} as unknown as Parameters<typeof api.get>[2])
|
||||
|
||||
triggerDownload(blob, 'catalogue-produits.xlsx')
|
||||
}
|
||||
catch {
|
||||
toast.error({
|
||||
title: t('admin.products.toast.error'),
|
||||
message: t('admin.products.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(() => {
|
||||
loadProducts()
|
||||
loadFilterReferentials()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete : retour vers le catalogue + titre. -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('admin.products.form.back')"
|
||||
v-bind="{ ariaLabel: t('admin.products.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('admin.products.form.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- ── Formulaire principal de creation ───────────────────────────────
|
||||
Bouton « Valider » TOUJOURS actif (ERP-101) : la validation
|
||||
autoritaire est serveur, les erreurs 422 reviennent inline. -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Etat du produit : multi-select obligatoire (>= 1, RG-6.02). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="form.states"
|
||||
:options="stateOptions"
|
||||
:label="t('admin.products.form.states')"
|
||||
:display-tag="true"
|
||||
:required="true"
|
||||
:error="errors.states"
|
||||
@update:model-value="(v: (string | number)[]) => setStates(v.map(String))"
|
||||
/>
|
||||
<!-- Sites de disponibilite : multi-select obligatoire (>= 1, RG-6.04).
|
||||
Pilote la cascade Type de stockage (RG-6.06). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="form.siteIris"
|
||||
:options="siteOptions"
|
||||
:label="t('admin.products.form.sites')"
|
||||
:display-tag="true"
|
||||
:required="true"
|
||||
:error="errors.sites"
|
||||
@update:model-value="(v: (string | number)[]) => setSites(v.map(String))"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:label="t('admin.products.form.name')"
|
||||
:required="true"
|
||||
:error="errors.name"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.code"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('admin.products.form.code')"
|
||||
:required="true"
|
||||
:error="errors.code"
|
||||
/>
|
||||
<!-- Categorie produit : select simple obligatoire, filtre type PRODUIT (RG-6.05). -->
|
||||
<MalioSelect
|
||||
:model-value="form.categoryIri"
|
||||
:options="categoryOptions"
|
||||
:label="t('admin.products.form.category')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="errors.category"
|
||||
@update:model-value="(v: string | number | null) => setCategory(v === null || v === '' ? null : String(v))"
|
||||
/>
|
||||
<!-- Type de stockage : multi-select obligatoire (>= 1), options filtrees
|
||||
par les sites selectionnes (RG-6.06). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="form.storageTypeIris"
|
||||
:options="storageTypeOptions"
|
||||
:label="t('admin.products.form.storageTypes')"
|
||||
:display-tag="true"
|
||||
:required="true"
|
||||
:error="errors.storageTypes"
|
||||
@update:model-value="(v: (string | number)[]) => setStorageTypes(v.map(String))"
|
||||
/>
|
||||
<!-- RG-6.03 : « Fabriqué » + « Contient de la mélasse » visibles
|
||||
uniquement si l'Etat contient « Vendu ». -->
|
||||
<MalioCheckbox
|
||||
v-if="isSale"
|
||||
v-model="form.manufactured"
|
||||
:label="t('admin.products.form.manufactured')"
|
||||
group-class="self-center"
|
||||
/>
|
||||
<MalioCheckbox
|
||||
v-if="isSale"
|
||||
v-model="form.containsMolasses"
|
||||
:label="t('admin.products.form.containsMolasses')"
|
||||
group-class="self-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('admin.products.form.submit')"
|
||||
:disabled="submitting"
|
||||
@click="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Onglets Fournisseurs / Clients en placeholder (HP-M6-01) : presents
|
||||
des l'ajout pour coherence avec l'ecran de modification. -->
|
||||
<ProductPlaceholderTabs />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useProductForm, PRODUCT_STATES } from '~/modules/catalog/composables/useProductForm'
|
||||
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('admin.products.form.title') })
|
||||
|
||||
// Gating de la route : la creation est reservee a `manage` (catalogue admin-only).
|
||||
if (!can('catalog.products.manage')) {
|
||||
await navigateTo('/admin/products')
|
||||
}
|
||||
|
||||
const {
|
||||
form,
|
||||
errors,
|
||||
submitting,
|
||||
isSale,
|
||||
siteOptions,
|
||||
categoryOptions,
|
||||
storageTypeOptions,
|
||||
setStates,
|
||||
setCategory,
|
||||
setStorageTypes,
|
||||
setSites,
|
||||
loadReferentials,
|
||||
submit,
|
||||
} = useProductForm()
|
||||
|
||||
// Options de l'etat : libelles i18n (la valeur d'option = code enum).
|
||||
const stateOptions = computed(() =>
|
||||
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
|
||||
)
|
||||
|
||||
/** Retour vers le catalogue produit (fleche d'en-tete). */
|
||||
function goBack(): void {
|
||||
router.push('/admin/products')
|
||||
}
|
||||
|
||||
/** Soumet la creation ; au succes, retour a la liste. */
|
||||
async function onSubmit(): Promise<void> {
|
||||
const ok = await submit()
|
||||
if (ok) {
|
||||
router.push('/admin/products')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Echec du chargement des referentiels non bloquant : les selects restent vides.
|
||||
loadReferentials().catch(() => {})
|
||||
})
|
||||
</script>
|
||||
@@ -4,15 +4,15 @@
|
||||
* Contrats API consommes :
|
||||
* - GET /api/categories → HydraCollection<Category>
|
||||
* - GET /api/categories/{id} → Category
|
||||
* - POST /api/categories → body { name, categoryType: IRI }
|
||||
* - PATCH /api/categories/{id} → body partiel { name?, categoryType?: IRI }
|
||||
* - POST /api/categories → body { name, categoryTypes: IRI[] }
|
||||
* - PATCH /api/categories/{id} → body partiel { name?, categoryTypes?: IRI[] }
|
||||
* - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor)
|
||||
* - GET /api/category_types → HydraCollection<CategoryType>
|
||||
*
|
||||
* Notes :
|
||||
* - Les IRI sont envoyes en POST/PATCH (ex. "/api/category_types/3").
|
||||
* - `categoryType` est embarque (groupe Serializer `category:read` sur les
|
||||
* proprietes de CategoryType, cf. spec-back § 3.4).
|
||||
* - Les IRI sont envoyes en POST/PATCH (ex. ["/api/category_types/3"]).
|
||||
* - `categoryTypes` est embarque (groupe Serializer `category:read` sur les
|
||||
* proprietes de CategoryType) : tableau d'objets type en lecture.
|
||||
* - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP,
|
||||
* ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null.
|
||||
*/
|
||||
@@ -43,7 +43,8 @@ export interface CategoryType {
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
categoryType: CategoryType
|
||||
/** Types de la categorie (>= 1, ManyToMany embarque en lecture). */
|
||||
categoryTypes: CategoryType[]
|
||||
/** Soft delete : null = active, valeur = supprimee logiquement le {date}. */
|
||||
deletedAt: string | null
|
||||
createdAt: string
|
||||
@@ -53,12 +54,12 @@ export interface Category {
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload accepte en POST /api/categories. `categoryType` est envoye en
|
||||
* IRI Hydra (ex. `/api/category_types/3`).
|
||||
* Payload accepte en POST /api/categories. `categoryTypes` est un tableau
|
||||
* d'IRI Hydra (ex. `['/api/category_types/3', '/api/category_types/5']`).
|
||||
*/
|
||||
export interface CategoryCreateInput {
|
||||
name: string
|
||||
categoryType: string
|
||||
categoryTypes: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,5 +68,5 @@ export interface CategoryCreateInput {
|
||||
*/
|
||||
export interface CategoryUpdateInput {
|
||||
name?: string
|
||||
categoryType?: string
|
||||
categoryTypes?: string[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Types front du module Catalog (M6 — Catalogue produit).
|
||||
*
|
||||
* Contrats API consommes :
|
||||
* - GET /api/products → HydraCollection<Product>
|
||||
* - GET /api/products/{id} → Product
|
||||
* - GET /api/products/export.xlsx → binaire XLSX (export complet, filtres actifs)
|
||||
*
|
||||
* Notes (cf. spec-back § 4.0.bis, contrat JSON capture en ERP-203) :
|
||||
* - `category` est embarque (objet, pas IRI) ; idem `sites` / `storageTypes`
|
||||
* (tableaux d'objets bornes). On n'a besoin que de `category.name` en liste.
|
||||
* - `states` est un tableau de chaines (PURCHASE / SALE / OTHER).
|
||||
* - `skip_null_values` actif cote back : ne pas presumer la presence des nulls.
|
||||
*/
|
||||
|
||||
/** Type de categorie embarque dans `category.categoryTypes` (RG-6.05). */
|
||||
export interface ProductCategoryType {
|
||||
id: number
|
||||
code: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Categorie embarquee dans un produit (lecture seule, sous-ensemble utile au front). */
|
||||
export interface ProductCategory {
|
||||
/** IRI Hydra, ex. `/api/categories/12` — utilise pour pre-selectionner le select en edition. */
|
||||
'@id': string
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
categoryTypes?: ProductCategoryType[]
|
||||
}
|
||||
|
||||
/** Site de disponibilite embarque dans un produit (groupe `site:read`). */
|
||||
export interface ProductSite {
|
||||
/** IRI Hydra, ex. `/api/sites/1` — utilise pour pre-selectionner le multi-select en edition. */
|
||||
'@id': string
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
postalCode: string
|
||||
city: string
|
||||
color: string
|
||||
fullAddress: string
|
||||
}
|
||||
|
||||
/** Type de stockage embarque dans un produit (referentiel borne, § 2.4). */
|
||||
export interface ProductStorageType {
|
||||
/** IRI Hydra, ex. `/api/storage_types/9` — utilise pour pre-selectionner le multi-select en edition. */
|
||||
'@id': string
|
||||
id: number
|
||||
code: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Produit metier — tel qu'il est lu depuis l'API. L'entite porte le pattern
|
||||
* Timestampable+Blamable (cf. spec-back § 2.8).
|
||||
*/
|
||||
export interface Product {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
/** Etats : sous-ensemble de PURCHASE / SALE / OTHER (RG-6.02). */
|
||||
states: string[]
|
||||
manufactured: boolean
|
||||
containsMolasses: boolean
|
||||
category: ProductCategory | null
|
||||
sites: ProductSite[]
|
||||
storageTypes: ProductStorageType[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -1,170 +1,226 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||
(pas de bordure sous le dernier bloc). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="p-0"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Usage de l'adresse : Prospect exclusif de Livraison/Facturation
|
||||
(RG-1.06/07/08). L'exclusivite est appliquee au toggle (cocher l'un
|
||||
decoche l'autre) plutot qu'en masquant les options. -->
|
||||
<MalioCheckbox
|
||||
:model-value="model.isProspect"
|
||||
:label="t('commercial.clients.form.address.prospect')"
|
||||
group-class="self-center"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: boolean) => toggleFlag('isProspect', v)"
|
||||
/>
|
||||
<MalioCheckbox
|
||||
:model-value="model.isDelivery"
|
||||
:label="t('commercial.clients.form.address.delivery')"
|
||||
group-class="self-center"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: boolean) => toggleFlag('isDelivery', v)"
|
||||
/>
|
||||
<MalioCheckbox
|
||||
:model-value="model.isBilling"
|
||||
:label="t('commercial.clients.form.address.billing')"
|
||||
group-class="self-center"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: boolean) => toggleFlag('isBilling', v)"
|
||||
/>
|
||||
|
||||
<!-- Cellule vide : laisse un trou en position 4 (ligne 1) pour que
|
||||
Categorie reparte au debut de la ligne suivante. -->
|
||||
<div aria-hidden="true" />
|
||||
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.categoryIris"
|
||||
:options="categoryOptions"
|
||||
:label="t('commercial.clients.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:disabled="readonly"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('commercial.clients.form.address.country')"
|
||||
:disabled="readonly"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.postalCode"
|
||||
:label="t('commercial.clients.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). En mode
|
||||
degrade (service indisponible), bascule en saisie libre. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('commercial.clients.form.address.city')"
|
||||
:disabled="readonly"
|
||||
empty-option-label=""
|
||||
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.city"
|
||||
:label="t('commercial.clients.form.address.city')"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
|
||||
<!-- Adresse + Adresse complementaire sur 2 colonnes : on wrappe car
|
||||
MalioInputText/Autocomplete (inheritAttrs:false) renvoient `class`
|
||||
sur l'input interne, pas sur la cellule de grille. Le wrapper porte
|
||||
le col-span-2, le champ le remplit (w-full). -->
|
||||
<div class="col-span-2">
|
||||
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple en
|
||||
mode degrade OU en lecture seule (MalioInputAutocomplete ne reaffiche
|
||||
pas sa valeur liee, il n'afficherait rien en readonly). -->
|
||||
<MalioInputAutocomplete
|
||||
v-if="!degraded && !readonly"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('commercial.clients.form.address.street')"
|
||||
<!-- Grille 4 colonnes des champs de l'adresse. -->
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Usage de l'adresse : Select unique (plus simple pour l'utilisateur)
|
||||
remplacant les 3 cases. Les options encodent les combinaisons valides
|
||||
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
|
||||
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). -->
|
||||
<!-- Erreur portee sur `isProspect` cote back (Callback type obligatoire +
|
||||
exclusivite prospect) -> affichee sous le select Type d'adresse. -->
|
||||
<MalioSelect
|
||||
:model-value="addressType"
|
||||
:options="addressTypeOptions"
|
||||
:label="t('commercial.clients.form.address.addressType')"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||
@search="onAddressSearch"
|
||||
@select="onAddressSelect"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.isProspect"
|
||||
@update:model-value="onAddressTypeChange"
|
||||
/>
|
||||
|
||||
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-1.10). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.siteIris"
|
||||
:options="siteOptions"
|
||||
:label="t('commercial.clients.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
: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))"
|
||||
/>
|
||||
|
||||
<!-- Email(s) de facturation : visible/obligatoire seulement si Facturation
|
||||
(RG-1.11). Le « + » revele un 2e email optionnel (max 2, pendant du
|
||||
telephone secondaire) qui coule dans la grille. Sinon un filler comble
|
||||
la colonne pour que Categorie reparte au debut de la ligne suivante. -->
|
||||
<MalioInputEmail
|
||||
v-if="isBillingEmailRequired(model)"
|
||||
:model-value="model.billingEmail"
|
||||
:label="t('commercial.clients.form.address.billingEmail')"
|
||||
:required="!readonly && !disabled"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.billingEmail"
|
||||
:addable="!model.hasSecondaryBillingEmail && !readonly"
|
||||
:add-button-label="t('commercial.clients.form.address.addBillingEmail')"
|
||||
@update:model-value="(v: string) => update('billingEmail', v)"
|
||||
@add="revealSecondaryBillingEmail"
|
||||
/>
|
||||
<!-- 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)"
|
||||
/>
|
||||
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.categoryIris"
|
||||
:options="categoryOptions"
|
||||
:label="t('commercial.clients.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('commercial.clients.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.postalCode"
|
||||
:label="t('commercial.clients.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). Si la BAN est
|
||||
indisponible, bascule en saisie libre — recuperable : re-saisir le
|
||||
code postal relance la recherche et repasse en select au succes. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('commercial.clients.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('commercial.clients.form.address.street')"
|
||||
:model-value="model.city"
|
||||
:label="t('commercial.clients.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
|
||||
<!-- Adresse + Adresse complementaire sur 2 colonnes : on wrappe car
|
||||
MalioInputText/Autocomplete (inheritAttrs:false) renvoient `class`
|
||||
sur l'input interne, pas sur la cellule de grille. Le wrapper porte
|
||||
le col-span-2, le champ le remplit (w-full). -->
|
||||
<div class="col-span-2">
|
||||
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple
|
||||
seulement en lecture seule (MalioInputAutocomplete ne reaffiche pas
|
||||
sa valeur liee, il n'afficherait rien en readonly). allow-create :
|
||||
si la BAN ne propose rien (ou erreur), le texte saisi est CONSERVE au
|
||||
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 && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('commercial.clients.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('commercial.clients.form.address.streetNotFound')"
|
||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||
@search="onAddressSearch"
|
||||
@select="onAddressSelect"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('commercial.clients.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('commercial.clients.form.address.streetComplement')"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sites Starseed : cases a cocher inline (>= 1 obligatoire, RG-1.10). -->
|
||||
<div class="flex justify-between">
|
||||
<MalioCheckbox
|
||||
v-for="site in siteOptions"
|
||||
:key="site.value"
|
||||
:model-value="model.siteIris.includes(site.value)"
|
||||
:label="site.label"
|
||||
group-class="w-auto self-center"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: boolean) => toggleSite(site.value, v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('commercial.clients.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:disabled="readonly"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Email de facturation : visible/obligatoire seulement si Facturation
|
||||
est coche (RG-1.11). -->
|
||||
<MalioInputText
|
||||
v-if="isBillingEmailRequired(model)"
|
||||
:model-value="model.billingEmail"
|
||||
:label="t('commercial.clients.form.address.billingEmail')"
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string) => update('billingEmail', v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
applyProspectExclusivity,
|
||||
addressFlagsFromType,
|
||||
addressTypeFromFlags,
|
||||
isBillingEmailRequired,
|
||||
type AddressFlagsDraft,
|
||||
} from '~/modules/commercial/utils/clientFormRules'
|
||||
type AddressType,
|
||||
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
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 = '#####'
|
||||
@@ -182,7 +238,15 @@ const props = defineProps<{
|
||||
/** Pays disponibles (France par defaut). */
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||
last?: 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>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -197,11 +261,35 @@ const autocomplete = useAddressAutocomplete()
|
||||
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
// Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre.
|
||||
// Type d'adresse (Select unique) derive des drapeaux back. null tant qu'aucun
|
||||
// drapeau n'est pose -> champ vide + bouton « Valider » bloque (cf. parent).
|
||||
const addressType = computed<AddressType | null>(() => addressTypeFromFlags(model.value))
|
||||
|
||||
const addressTypeOptions = computed<RefOption[]>(() => [
|
||||
{ value: 'prospect', label: t('commercial.clients.form.address.addressTypeProspect') },
|
||||
{ value: 'delivery', label: t('commercial.clients.form.address.addressTypeDelivery') },
|
||||
{ value: 'billing', label: t('commercial.clients.form.address.addressTypeBilling') },
|
||||
{ value: 'delivery_billing', label: t('commercial.clients.form.address.addressTypeDeliveryBilling') },
|
||||
{ value: 'broker', label: t('commercial.clients.form.address.addressTypeBroker') },
|
||||
{ value: 'distributor', label: t('commercial.clients.form.address.addressTypeDistributor') },
|
||||
])
|
||||
|
||||
/** Applique le type choisi en repercutant les 3 drapeaux back (immutabilite). */
|
||||
function onAddressTypeChange(value: string | number | null): void {
|
||||
if (value === null) return
|
||||
emit('update:modelValue', { ...props.modelValue, ...addressFlagsFromType(value as AddressType) })
|
||||
}
|
||||
|
||||
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable :
|
||||
// remis a false des qu'une recherche de ville aboutit). N'affecte plus le champ
|
||||
// Adresse, qui reste en autocompletion et reessaie a chaque frappe.
|
||||
const degraded = ref(false)
|
||||
// Avertissement « service indisponible » envoye au parent une seule fois.
|
||||
let unavailableNotified = false
|
||||
// Villes proposees par la BAN (alimentees a la saisie du code postal).
|
||||
const banCityOptions = ref<RefOption[]>([])
|
||||
const addressOptions = ref<RefOption[]>([])
|
||||
// Adresses proposees par la BAN (alimentees a la saisie d'adresse).
|
||||
const banAddressOptions = ref<RefOption[]>([])
|
||||
|
||||
// Options ville effectives : on garantit que la ville courante figure toujours
|
||||
// dans la liste, sinon MalioSelect (qui resout le libelle depuis ses options)
|
||||
@@ -214,65 +302,113 @@ const cityOptions = computed<RefOption[]>(() => {
|
||||
}
|
||||
return banCityOptions.value
|
||||
})
|
||||
|
||||
// Meme garantie que cityOptions pour le champ Adresse : la rue courante doit
|
||||
// toujours figurer dans les options, sinon MalioInputAutocomplete (qui resout
|
||||
// l'affichage depuis ses options) laisse le champ VIDE des que la liste de
|
||||
// suggestions BAN est vide — typiquement juste apres validation (remontage) ou
|
||||
// a l'edition d'une adresse existante (1.12), alors que la valeur est bien
|
||||
// persistee. On reinjecte donc la rue liee si la BAN ne l'a pas (re)proposee.
|
||||
const addressOptions = computed<RefOption[]>(() => {
|
||||
const current = props.modelValue.street
|
||||
if (current && !banAddressOptions.value.some(o => o.value === current)) {
|
||||
return [{ value: current, label: current }, ...banAddressOptions.value]
|
||||
}
|
||||
return banAddressOptions.value
|
||||
})
|
||||
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 })
|
||||
}
|
||||
|
||||
/** Coche/decoche un site Starseed rattache a l'adresse (M2M par IRI, RG-1.10). */
|
||||
function toggleSite(siteIri: string, selected: boolean): void {
|
||||
const current = props.modelValue.siteIris
|
||||
const next = selected
|
||||
? [...current, siteIri]
|
||||
: current.filter(iri => iri !== siteIri)
|
||||
update('siteIris', next)
|
||||
/**
|
||||
* 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,
|
||||
})
|
||||
}
|
||||
|
||||
/** Applique l'exclusivite Prospect / (Livraison|Facturation) au changement. */
|
||||
function toggleFlag(field: keyof AddressFlagsDraft, value: boolean): void {
|
||||
const flags = applyProspectExclusivity(
|
||||
{ isProspect: model.value.isProspect, isDelivery: model.value.isDelivery, isBilling: model.value.isBilling },
|
||||
field,
|
||||
value,
|
||||
)
|
||||
emit('update:modelValue', { ...props.modelValue, ...flags })
|
||||
/** Revele le 2e champ email de facturation (clic sur le « + »). */
|
||||
function revealSecondaryBillingEmail(): void {
|
||||
emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true })
|
||||
}
|
||||
|
||||
/** Bascule définitivement en mode degrade et previent le parent (toast unique). */
|
||||
function enterDegraded(): void {
|
||||
if (!degraded.value) {
|
||||
degraded.value = true
|
||||
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
||||
function notifyUnavailable(): void {
|
||||
if (!unavailableNotified) {
|
||||
unavailableNotified = true
|
||||
emit('degraded')
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)
|
||||
|
||||
if (degraded.value) {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
try {
|
||||
const suggestions = await autocomplete.searchCity(digits)
|
||||
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
|
||||
// Service repondu : on (re)passe la Ville en select assiste.
|
||||
degraded.value = false
|
||||
}
|
||||
catch {
|
||||
enterDegraded()
|
||||
// BAN indispo : Ville en saisie libre (recuperable au prochain essai).
|
||||
degraded.value = true
|
||||
notifyUnavailable()
|
||||
}
|
||||
}
|
||||
|
||||
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
|
||||
async function onAddressSearch(query: string): Promise<void> {
|
||||
if (degraded.value) {
|
||||
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400)
|
||||
// et on vide les suggestions devenues obsoletes.
|
||||
if (query.trim().length < 3) {
|
||||
banAddressOptions.value = []
|
||||
return
|
||||
}
|
||||
addressLoading.value = true
|
||||
@@ -280,10 +416,13 @@ async function onAddressSearch(query: string): Promise<void> {
|
||||
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
|
||||
const suggestions = await autocomplete.searchAddress(query, postalCode)
|
||||
lastAddressSuggestions = suggestions
|
||||
addressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
|
||||
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
|
||||
}
|
||||
catch {
|
||||
enterDegraded()
|
||||
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie
|
||||
// (pas de bascule definitive — c'etait le bug). Avertissement une seule fois.
|
||||
banAddressOptions.value = []
|
||||
notifyUnavailable()
|
||||
}
|
||||
finally {
|
||||
addressLoading.value = false
|
||||
|
||||
@@ -1,64 +1,100 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||
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"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||
(pas de bordure sous le dernier bloc). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||
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 && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="p-0"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.lastName"
|
||||
:label="t('commercial.clients.form.contact.lastName')"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="model.firstName"
|
||||
:label="t('commercial.clients.form.contact.firstName')"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('commercial.clients.form.contact.jobTitle')"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
<MalioInputEmail
|
||||
:model-value="model.email"
|
||||
:label="t('commercial.clients.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('commercial.clients.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('commercial.clients.form.contact.addPhone')"
|
||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('commercial.clients.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
<!-- Grille 4 colonnes des champs du contact. -->
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<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 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')"
|
||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
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)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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).
|
||||
@@ -71,8 +107,16 @@ const props = defineProps<{
|
||||
title: string
|
||||
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-1.14). */
|
||||
removable?: boolean
|
||||
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||
last?: 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>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -85,6 +129,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 })
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
<template>
|
||||
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||
(pas de bordure sous le dernier bloc). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||
<!-- Suppression : modal de confirmation cote parent. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="p-0"
|
||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.address.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Grille 4 colonnes des champs de l'adresse. -->
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Type d'adresse : Prospect / Depart / Rendu (RG-2.09). Select en attendant
|
||||
l'arbitrage metier (radio vs select) ; l'erreur 422 (propertyPath
|
||||
`addressType`) s'affiche via la prop native :error de MalioSelect. -->
|
||||
<MalioSelect
|
||||
:model-value="model.addressType"
|
||||
:options="addressTypeOptions"
|
||||
:label="t('commercial.suppliers.form.address.addressType')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.addressType"
|
||||
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
|
||||
/>
|
||||
|
||||
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-2.06). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.siteIris"
|
||||
:options="siteOptions"
|
||||
:label="t('commercial.suppliers.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
: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). 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
|
||||
:model-value="model.categoryIris"
|
||||
:options="categoryOptions"
|
||||
:label="t('commercial.suppliers.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('commercial.suppliers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.postalCode"
|
||||
:label="t('commercial.suppliers.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
: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"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('commercial.suppliers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.city"
|
||||
:label="t('commercial.suppliers.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
: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">
|
||||
<MalioInputAutocomplete
|
||||
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"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('commercial.suppliers.form.address.streetNotFound')"
|
||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||
@search="onAddressSearch"
|
||||
@select="onAddressSelect"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('commercial.suppliers.form.address.street')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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). 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).
|
||||
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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 = '#####'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Brouillon de l'adresse (v-model). */
|
||||
modelValue: SupplierAddressFormDraft
|
||||
title: string
|
||||
/** Categories autorisees sur une adresse (type FOURNISSEUR). */
|
||||
categoryOptions: CategoryOption[]
|
||||
/** Sites Starseed disponibles. */
|
||||
siteOptions: RefOption[]
|
||||
/** Contacts deja saisis, rattachables a l'adresse. */
|
||||
contactOptions: RefOption[]
|
||||
/** Pays disponibles (France par defaut). */
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||
last?: 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>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: SupplierAddressFormDraft]
|
||||
'remove': []
|
||||
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
|
||||
'degraded': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const autocomplete = useAddressAutocomplete()
|
||||
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
const addressTypeOptions = computed<{ value: SupplierAddressType, label: string }[]>(() => [
|
||||
{ value: 'PROSPECT', label: t('commercial.suppliers.form.address.addressTypeProspect') },
|
||||
{ value: 'DEPART', label: t('commercial.suppliers.form.address.addressTypeDepart') },
|
||||
{ value: 'RENDU', label: t('commercial.suppliers.form.address.addressTypeRendu') },
|
||||
])
|
||||
|
||||
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable).
|
||||
const degraded = ref(false)
|
||||
let unavailableNotified = false
|
||||
const banCityOptions = ref<RefOption[]>([])
|
||||
const banAddressOptions = ref<RefOption[]>([])
|
||||
|
||||
// Options ville effectives : on garantit que la ville courante figure toujours
|
||||
// dans la liste, sinon MalioSelect afficherait un champ vide en lecture seule.
|
||||
const cityOptions = computed<RefOption[]>(() => {
|
||||
const current = props.modelValue.city
|
||||
if (current && !banCityOptions.value.some(o => o.value === current)) {
|
||||
return [{ value: current, label: current }, ...banCityOptions.value]
|
||||
}
|
||||
return banCityOptions.value
|
||||
})
|
||||
|
||||
// Meme garantie pour le champ Adresse : la rue courante doit toujours figurer
|
||||
// dans les options, sinon MalioInputAutocomplete laisse le champ vide.
|
||||
const addressOptions = computed<RefOption[]>(() => {
|
||||
const current = props.modelValue.street
|
||||
if (current && !banAddressOptions.value.some(o => o.value === current)) {
|
||||
return [{ value: current, label: current }, ...banAddressOptions.value]
|
||||
}
|
||||
return banAddressOptions.value
|
||||
})
|
||||
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) {
|
||||
unavailableNotified = true
|
||||
emit('degraded')
|
||||
}
|
||||
}
|
||||
|
||||
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
|
||||
async function onPostalCodeChange(value: string): Promise<void> {
|
||||
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
|
||||
}
|
||||
try {
|
||||
const suggestions = await autocomplete.searchCity(digits)
|
||||
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
|
||||
degraded.value = false
|
||||
}
|
||||
catch {
|
||||
degraded.value = true
|
||||
notifyUnavailable()
|
||||
}
|
||||
}
|
||||
|
||||
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
|
||||
async function onAddressSearch(query: string): Promise<void> {
|
||||
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400).
|
||||
if (query.trim().length < 3) {
|
||||
banAddressOptions.value = []
|
||||
return
|
||||
}
|
||||
addressLoading.value = true
|
||||
try {
|
||||
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
|
||||
const suggestions = await autocomplete.searchAddress(query, postalCode)
|
||||
lastAddressSuggestions = suggestions
|
||||
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
|
||||
}
|
||||
catch {
|
||||
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie.
|
||||
banAddressOptions.value = []
|
||||
notifyUnavailable()
|
||||
}
|
||||
finally {
|
||||
addressLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Selection d'une suggestion d'adresse → remplit rue + ville + CP. */
|
||||
function onAddressSelect(option: { label: string, value: string | number } | null): void {
|
||||
if (option === null) {
|
||||
return
|
||||
}
|
||||
const suggestion = lastAddressSuggestions.find(s => s.street === option.value)
|
||||
if (!suggestion) {
|
||||
update('street', String(option.value))
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
street: suggestion.street,
|
||||
city: suggestion.city,
|
||||
postalCode: suggestion.postalCode,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
|
||||
(pas de bordure sous le dernier bloc). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
|
||||
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
|
||||
<!-- 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 && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="p-0"
|
||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Grille 4 colonnes des champs du contact. -->
|
||||
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<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 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')"
|
||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
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)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 = '## ## ## ## ##'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Brouillon du contact (v-model). */
|
||||
modelValue: SupplierContactFormDraft
|
||||
/** Titre du bloc (ex: « Contact 1 »). */
|
||||
title: string
|
||||
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-2.13). */
|
||||
removable?: boolean
|
||||
/** Dernier bloc de la liste : supprime le filet de separation bas. */
|
||||
last?: 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>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: SupplierContactFormDraft]
|
||||
'remove': []
|
||||
}>()
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
/** Revele le 2e numero (max 1 secondaire, le « + » disparait). */
|
||||
function revealSecondaryPhone(): void {
|
||||
emit('update:modelValue', { ...props.modelValue, hasSecondaryPhone: true })
|
||||
}
|
||||
</script>
|
||||
@@ -1,14 +0,0 @@
|
||||
<template>
|
||||
<!--
|
||||
Placeholder des onglets non encore implementes (Transport, Statistiques,
|
||||
Rapports, Echanges). Frame vide blanche : aucun champ, aucun bouton,
|
||||
aucun message « En cours » (decision Tristan 28/05). L'orchestrateur passe
|
||||
automatiquement a l'onglet suivant — ce composant n'est qu'une coquille
|
||||
visuelle reutilisee par 1.11/1.12.
|
||||
-->
|
||||
<div class="min-h-[240px] rounded-md bg-white" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Composant purement presentationnel : aucune prop, aucun event.
|
||||
</script>
|
||||
@@ -0,0 +1,402 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref, computed } from 'vue'
|
||||
import { emptyAddress } from '~/modules/commercial/types/clientForm'
|
||||
import ClientAddressBlock from '../ClientAddressBlock.vue'
|
||||
|
||||
// Mocks controlables du composable BAN (hoisted) : chaque test configure le
|
||||
// comportement de searchCity / searchAddress (succes, rejet, rejet-puis-succes).
|
||||
// Par defaut ils renvoient undefined (aucune suggestion) — etat « adresse
|
||||
// persistee mais liste vide » couvert par les tests d'affichage.
|
||||
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
|
||||
searchCityMock: vi.fn(),
|
||||
searchAddressMock: vi.fn(),
|
||||
}))
|
||||
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
|
||||
useAddressAutocomplete: () => ({
|
||||
searchCity: searchCityMock,
|
||||
searchAddress: searchAddressMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('ref', ref)
|
||||
vi.stubGlobal('computed', computed)
|
||||
|
||||
// Stub de MalioInputAutocomplete : expose les `value` des options recues, pour
|
||||
// verifier que la rue courante figure bien dans la liste (sinon le composant
|
||||
// Malio ne peut pas resoudre/afficher la valeur liee -> champ vide).
|
||||
const MalioInputAutocompleteStub = defineComponent({
|
||||
name: 'MalioInputAutocomplete',
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null], default: undefined },
|
||||
options: { type: Array as () => { value: string | number, label: string }[], default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
minSearchLength: { type: Number, default: 0 },
|
||||
label: { type: String, default: '' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
allowCreate: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ['update:modelValue', 'search', 'select'],
|
||||
setup(props) {
|
||||
return () => h('div', {
|
||||
'data-testid': 'addr-autocomplete',
|
||||
'data-options': JSON.stringify(props.options.map(o => o.value)),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function mountBlock(street: string | null) {
|
||||
return mount(ClientAddressBlock, {
|
||||
props: {
|
||||
modelValue: { ...emptyAddress(), street },
|
||||
title: 'Adresse',
|
||||
categoryOptions: [],
|
||||
siteOptions: [],
|
||||
contactOptions: [],
|
||||
countryOptions: [],
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioCheckbox: true,
|
||||
MalioSelect: true,
|
||||
MalioSelectCheckbox: true,
|
||||
MalioInputText: true,
|
||||
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('ClientAddressBlock — affichage de l\'adresse persistee', () => {
|
||||
it('inclut la rue courante dans les options de l\'autocomplete meme sans recherche BAN', () => {
|
||||
const wrapper = mountBlock('8 Boulevard du Port')
|
||||
|
||||
const el = wrapper.find('[data-testid="addr-autocomplete"]')
|
||||
const values = JSON.parse(el.attributes('data-options') ?? '[]')
|
||||
|
||||
expect(values).toContain('8 Boulevard du Port')
|
||||
})
|
||||
|
||||
// ERP-119 : saisie manuelle possible quand la BAN ne trouve rien -> allow-create
|
||||
// (sans cette prop, MalioInputAutocomplete efface le texte non selectionne au blur).
|
||||
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
|
||||
const wrapper = mountBlock(null)
|
||||
|
||||
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Stub MalioInputText qui re-expose `label` + `error` recus : permet de cibler
|
||||
* un champ par son libelle et de verifier l'erreur 422 propagee (ERP-101).
|
||||
*/
|
||||
const MalioInputTextProbe = defineComponent({
|
||||
name: 'MalioInputTextProbe',
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null], default: undefined },
|
||||
error: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('div', {
|
||||
'data-testid': 'addr-text',
|
||||
'data-label': props.label,
|
||||
'data-error': props.error,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
||||
function mountWithErrors(errors: Record<string, string>) {
|
||||
return mount(ClientAddressBlock, {
|
||||
props: {
|
||||
modelValue: emptyAddress(),
|
||||
title: 'Adresse',
|
||||
categoryOptions: [],
|
||||
siteOptions: [],
|
||||
contactOptions: [],
|
||||
countryOptions: [],
|
||||
errors,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioCheckbox: true,
|
||||
MalioSelect: true,
|
||||
MalioSelectCheckbox: true,
|
||||
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||
MalioInputText: MalioInputTextProbe,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
it('affiche l\'erreur serveur sur le champ code postal via la prop errors', () => {
|
||||
const wrapper = mountWithErrors({ postalCode: 'Code postal invalide.' })
|
||||
|
||||
const field = wrapper.findAll('[data-testid="addr-text"]').find(
|
||||
el => el.attributes('data-label') === 'commercial.clients.form.address.postalCode',
|
||||
)
|
||||
expect(field?.attributes('data-error')).toBe('Code postal invalide.')
|
||||
})
|
||||
|
||||
// ERP-119 : type d'adresse (propertyPath back `isProspect`), sites et
|
||||
// categories sont obligatoires ; leurs violations 422 doivent s'afficher sous
|
||||
// le champ correspondant (bindings :error de ClientAddressBlock).
|
||||
it('affiche l\'erreur serveur sur type d\'adresse (propertyPath isProspect)', () => {
|
||||
const wrapper = mountWithErrors({ isProspect: 'Le type d\'adresse est obligatoire.' })
|
||||
|
||||
const field = wrapper.findAll('malio-select-stub').find(
|
||||
el => el.attributes('label') === 'commercial.clients.form.address.addressType',
|
||||
)
|
||||
expect(field?.attributes('error')).toBe('Le type d\'adresse est obligatoire.')
|
||||
})
|
||||
|
||||
it('affiche les erreurs serveur sur sites et categories', () => {
|
||||
const wrapper = mountWithErrors({
|
||||
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') === 'commercial.clients.form.address.sites')
|
||||
const categoriesField = checkboxes.find(el => el.attributes('label') === 'commercial.clients.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.')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 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()
|
||||
})
|
||||
|
||||
it('n\'appelle pas la BAN en deca de 3 caracteres', async () => {
|
||||
const wrapper = mountBlock(null)
|
||||
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||
|
||||
auto.vm.$emit('search', 'ab')
|
||||
await flushPromises()
|
||||
|
||||
expect(searchAddressMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('relance la recherche apres une erreur (pas de bascule definitive)', async () => {
|
||||
searchAddressMock
|
||||
.mockRejectedValueOnce(new Error('BAN indisponible'))
|
||||
.mockResolvedValueOnce([
|
||||
{ label: '8 Boulevard du Port, Paris', street: '8 Boulevard du Port', postalCode: '75001', city: 'Paris' },
|
||||
])
|
||||
|
||||
const wrapper = mountBlock(null)
|
||||
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||
|
||||
// 1er essai -> erreur BAN.
|
||||
auto.vm.$emit('search', 'boulevard du port')
|
||||
await flushPromises()
|
||||
expect(searchAddressMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// 2e essai -> DOIT relancer l'appel (c'etait le bug : plus aucune recherche).
|
||||
auto.vm.$emit('search', 'boulevard du porte')
|
||||
await flushPromises()
|
||||
expect(searchAddressMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
// L'autocompletion reste montee (aucune bascule en saisie libre).
|
||||
expect(wrapper.find('[data-testid="addr-autocomplete"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emet « degraded » une seule fois malgre plusieurs erreurs', async () => {
|
||||
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
|
||||
|
||||
const wrapper = mountBlock(null)
|
||||
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||
|
||||
auto.vm.$emit('search', 'rue de la paix')
|
||||
await flushPromises()
|
||||
auto.vm.$emit('search', 'rue de la paixx')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('degraded')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref, computed } from 'vue'
|
||||
import { emptyContact } from '~/modules/commercial/types/clientForm'
|
||||
import ClientContactBlock from '../ClientContactBlock.vue'
|
||||
|
||||
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('ref', ref)
|
||||
vi.stubGlobal('computed', computed)
|
||||
|
||||
/**
|
||||
* Stub d'un champ Malio qui re-expose la prop `error` recue dans un attribut
|
||||
* data-* : permet de verifier que le bloc propage bien `:errors[champ]` sur le
|
||||
* bon champ (ERP-101 — mapping erreur 422 par champ, par ligne de collection).
|
||||
*/
|
||||
function errorProbe(testid: string) {
|
||||
return defineComponent({
|
||||
name: `Probe-${testid}`,
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null], default: undefined },
|
||||
error: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('div', { 'data-testid': testid, 'data-error': props.error })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function mountBlock(errors?: Record<string, string>) {
|
||||
return mount(ClientContactBlock, {
|
||||
props: {
|
||||
modelValue: emptyContact(),
|
||||
title: 'Contact 1',
|
||||
...(errors ? { errors } : {}),
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioInputPhone: true,
|
||||
MalioInputText: errorProbe('contact-text'),
|
||||
MalioInputEmail: errorProbe('contact-email'),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('ClientContactBlock — mapping erreur par champ (ERP-101)', () => {
|
||||
it('affiche l\'erreur serveur sur le champ email via la prop errors', () => {
|
||||
const wrapper = mountBlock({ email: 'Adresse e-mail invalide.' })
|
||||
|
||||
const email = wrapper.find('[data-testid="contact-email"]')
|
||||
expect(email.attributes('data-error')).toBe('Adresse e-mail invalide.')
|
||||
})
|
||||
|
||||
it('laisse les champs sans erreur quand errors est absent', () => {
|
||||
const wrapper = mountBlock()
|
||||
|
||||
const email = wrapper.find('[data-testid="contact-email"]')
|
||||
expect(email.attributes('data-error')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref, computed } from 'vue'
|
||||
import { emptyAddress } from '~/modules/commercial/types/supplierForm'
|
||||
import SupplierAddressBlock from '../SupplierAddressBlock.vue'
|
||||
|
||||
// Mocks controlables du composable BAN (hoisted).
|
||||
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
|
||||
searchCityMock: vi.fn(),
|
||||
searchAddressMock: vi.fn(),
|
||||
}))
|
||||
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
|
||||
useAddressAutocomplete: () => ({
|
||||
searchCity: searchCityMock,
|
||||
searchAddress: searchAddressMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('ref', ref)
|
||||
vi.stubGlobal('computed', computed)
|
||||
|
||||
// Stub de MalioInputAutocomplete : expose les `value` des options + allowCreate.
|
||||
const MalioInputAutocompleteStub = defineComponent({
|
||||
name: 'MalioInputAutocomplete',
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null], default: undefined },
|
||||
options: { type: Array as () => { value: string | number, label: string }[], default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
minSearchLength: { type: Number, default: 0 },
|
||||
label: { type: String, default: '' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
allowCreate: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ['update:modelValue', 'search', 'select'],
|
||||
setup(props) {
|
||||
return () => h('div', {
|
||||
'data-testid': 'addr-autocomplete',
|
||||
'data-options': JSON.stringify(props.options.map(o => o.value)),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<string, string>) {
|
||||
return mount(SupplierAddressBlock, {
|
||||
props: {
|
||||
modelValue: { ...emptyAddress(), ...overrides },
|
||||
title: 'Adresse 1',
|
||||
categoryOptions: [],
|
||||
siteOptions: [],
|
||||
contactOptions: [],
|
||||
countryOptions: [],
|
||||
...(errors ? { errors } : {}),
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioCheckbox: true,
|
||||
MalioInputNumber: true,
|
||||
MalioSelect: true,
|
||||
MalioSelectCheckbox: true,
|
||||
MalioInputText: true,
|
||||
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('SupplierAddressBlock — specificites M2 (type, bennes, triage)', () => {
|
||||
it('rend un select de type d\'adresse (en attendant l\'arbitrage metier)', () => {
|
||||
const wrapper = mountBlock()
|
||||
const addressTypeSelect = wrapper.findAll('malio-select-stub').find(
|
||||
el => el.attributes('label') === 'commercial.suppliers.form.address.addressType',
|
||||
)
|
||||
expect(addressTypeSelect).toBeDefined()
|
||||
})
|
||||
|
||||
it('rend le stepper Bennes et la case Prestation de triage (champs specifiques fournisseur)', () => {
|
||||
const wrapper = mountBlock()
|
||||
expect(wrapper.find('malio-input-number-stub').exists()).toBe(true)
|
||||
expect(wrapper.find('malio-checkbox-stub').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('ne rend aucun champ d\'email de facturation (difference M1)', () => {
|
||||
const wrapper = mountBlock()
|
||||
// Aucun MalioInputEmail dans le bloc adresse fournisseur.
|
||||
expect(wrapper.find('malio-input-email-stub').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SupplierAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
||||
it('affiche l\'erreur serveur du type d\'adresse (propertyPath addressType) sur le select', () => {
|
||||
const wrapper = mountBlock({}, { addressType: 'Le type d\'adresse est obligatoire.' })
|
||||
const addressTypeSelect = wrapper.findAll('malio-select-stub').find(
|
||||
el => el.attributes('label') === 'commercial.suppliers.form.address.addressType',
|
||||
)
|
||||
expect(addressTypeSelect?.attributes('error')).toBe('Le type d\'adresse est obligatoire.')
|
||||
})
|
||||
|
||||
it('affiche les erreurs serveur sur sites et categories', () => {
|
||||
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') === 'commercial.suppliers.form.address.sites')
|
||||
const categoriesField = checkboxes.find(el => el.attributes('label') === 'commercial.suppliers.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', () => {
|
||||
const wrapper = mountBlock({}, { postalCode: 'Code postal invalide.' })
|
||||
const field = wrapper.findAll('malio-input-text-stub').find(
|
||||
el => el.attributes('label') === 'commercial.suppliers.form.address.postalCode',
|
||||
)
|
||||
expect(field?.attributes('error')).toBe('Code postal invalide.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SupplierAddressBlock — autocompletion adresse (BAN) robuste', () => {
|
||||
beforeEach(() => {
|
||||
searchAddressMock.mockReset()
|
||||
})
|
||||
|
||||
it('n\'appelle pas la BAN en deca de 3 caracteres', async () => {
|
||||
const wrapper = mountBlock()
|
||||
wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab')
|
||||
await flushPromises()
|
||||
expect(searchAddressMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('relance la recherche apres une erreur (pas de bascule definitive)', async () => {
|
||||
searchAddressMock
|
||||
.mockRejectedValueOnce(new Error('BAN indisponible'))
|
||||
.mockResolvedValueOnce([
|
||||
{ label: '8 Boulevard du Port, Paris', street: '8 Boulevard du Port', postalCode: '75001', city: 'Paris' },
|
||||
])
|
||||
|
||||
const wrapper = mountBlock()
|
||||
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||
|
||||
auto.vm.$emit('search', 'boulevard du port')
|
||||
await flushPromises()
|
||||
auto.vm.$emit('search', 'boulevard du porte')
|
||||
await flushPromises()
|
||||
|
||||
expect(searchAddressMock).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.find('[data-testid="addr-autocomplete"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emet « degraded » une seule fois malgre plusieurs erreurs', async () => {
|
||||
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
|
||||
|
||||
const wrapper = mountBlock()
|
||||
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||
|
||||
auto.vm.$emit('search', 'rue de la paix')
|
||||
await flushPromises()
|
||||
auto.vm.$emit('search', 'rue de la paixx')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('degraded')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
|
||||
const wrapper = mountBlock()
|
||||
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
|
||||
})
|
||||
|
||||
it('inclut la rue courante dans les options meme sans recherche BAN', () => {
|
||||
const wrapper = mountBlock({ street: '8 Boulevard du Port' })
|
||||
const values = JSON.parse(wrapper.find('[data-testid="addr-autocomplete"]').attributes('data-options') ?? '[]')
|
||||
expect(values).toContain('8 Boulevard du Port')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref, computed } from 'vue'
|
||||
import { emptyContact } from '~/modules/commercial/types/supplierForm'
|
||||
import SupplierContactBlock from '../SupplierContactBlock.vue'
|
||||
|
||||
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('ref', ref)
|
||||
vi.stubGlobal('computed', computed)
|
||||
|
||||
/** Stub d'un champ Malio qui re-expose la prop `error` recue dans un data-* attribut. */
|
||||
function errorProbe(testid: string) {
|
||||
return defineComponent({
|
||||
name: `Probe-${testid}`,
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null], default: undefined },
|
||||
error: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('div', { 'data-testid': testid, 'data-error': props.error })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function mountBlock(errors?: Record<string, string>) {
|
||||
return mount(SupplierContactBlock, {
|
||||
props: {
|
||||
modelValue: emptyContact(),
|
||||
title: 'Contact 1',
|
||||
...(errors ? { errors } : {}),
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioInputPhone: true,
|
||||
MalioInputText: errorProbe('contact-text'),
|
||||
MalioInputEmail: errorProbe('contact-email'),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('SupplierContactBlock — mapping erreur par champ (ERP-101)', () => {
|
||||
it('affiche l\'erreur serveur sur le champ email via la prop errors', () => {
|
||||
const wrapper = mountBlock({ email: 'Adresse e-mail invalide.' })
|
||||
expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('Adresse e-mail invalide.')
|
||||
})
|
||||
|
||||
it('laisse les champs sans erreur quand errors est absent', () => {
|
||||
const wrapper = mountBlock()
|
||||
expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||
import { useClientFormErrors } from '../useClientFormErrors'
|
||||
|
||||
// useFormErrors (auto-import) expose l'implementation reelle ; elle consomme
|
||||
// useToast + useI18n, stubbes ici.
|
||||
vi.stubGlobal('useToast', () => ({ error: vi.fn(), success: vi.fn() }))
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('useFormErrors', useFormErrors)
|
||||
|
||||
/**
|
||||
* Tests du composable partage `useClientFormErrors` — factorisation du cablage
|
||||
* d'erreurs des ecrans client (creation/edition), suggestion de revue ERP-101.
|
||||
* `mapRowError` ne toaste plus : il retourne un booleen et chaque page garde son
|
||||
* propre fallback (toast.error en creation, showError en edition).
|
||||
*/
|
||||
describe('useClientFormErrors', () => {
|
||||
it('expose les 3 etats scalaires (vides) et les 3 tableaux d\'erreurs par ligne', () => {
|
||||
const f = useClientFormErrors()
|
||||
expect(f.mainErrors.errors).toEqual({})
|
||||
expect(f.informationErrors.errors).toEqual({})
|
||||
expect(f.accountingErrors.errors).toEqual({})
|
||||
expect(f.contactErrors.value).toEqual([])
|
||||
expect(f.addressErrors.value).toEqual([])
|
||||
expect(f.ribErrors.value).toEqual([])
|
||||
})
|
||||
|
||||
it('mapRowError mappe une 422 sur target[index] et retourne true', () => {
|
||||
const f = useClientFormErrors()
|
||||
const error = {
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'email', message: 'Adresse invalide.' }] },
|
||||
},
|
||||
}
|
||||
const mapped = f.mapRowError(error, f.contactErrors, 0)
|
||||
expect(mapped).toBe(true)
|
||||
expect(f.contactErrors.value[0]).toEqual({ email: 'Adresse invalide.' })
|
||||
})
|
||||
|
||||
it('mapRowError retourne false et ne touche pas la cible pour une erreur non-422', () => {
|
||||
const f = useClientFormErrors()
|
||||
const error = { response: { status: 500, _data: {} } }
|
||||
expect(f.mapRowError(error, f.ribErrors, 0)).toBe(false)
|
||||
expect(f.ribErrors.value[0]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('mapRowError retourne false pour une 422 sans violation exploitable', () => {
|
||||
const f = useClientFormErrors()
|
||||
const error = { response: { status: 422, _data: { 'hydra:description': 'Donnees invalides.' } } }
|
||||
expect(f.mapRowError(error, f.addressErrors, 0)).toBe(false)
|
||||
expect(f.addressErrors.value[0]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// Construit une erreur facon useApi : 422 avec violations Hydra.
|
||||
function http422(path: string, message: string) {
|
||||
return { response: { status: 422, _data: { violations: [{ propertyPath: path, message }] } } }
|
||||
}
|
||||
|
||||
/**
|
||||
* `submitRows` factorise la soumission d'une collection de blocs (contacts /
|
||||
* adresses / RIB) : on tente TOUS les blocs et on collecte les erreurs par index
|
||||
* sans stopper au premier echec (ERP-110 / ERP-101).
|
||||
*/
|
||||
describe('useClientFormErrors.submitRows', () => {
|
||||
it('tente TOUS les blocs et mappe les erreurs par index, sans stopper au premier echec', async () => {
|
||||
const { contactErrors, submitRows } = useClientFormErrors()
|
||||
const seen: number[] = []
|
||||
const onUnmapped = vi.fn()
|
||||
|
||||
const saveRow = async (_row: unknown, index: number) => {
|
||||
seen.push(index)
|
||||
if (index === 1) throw http422('email', 'Email invalide')
|
||||
}
|
||||
|
||||
const hasError = await submitRows(
|
||||
[{ a: 0 }, { a: 1 }, { a: 2 }],
|
||||
contactErrors,
|
||||
saveRow,
|
||||
onUnmapped,
|
||||
)
|
||||
|
||||
expect(seen).toEqual([0, 1, 2]) // tous les blocs tentes
|
||||
expect(hasError).toBe(true)
|
||||
expect(contactErrors.value[1]).toEqual({ email: 'Email invalide' })
|
||||
expect(contactErrors.value[0]).toBeUndefined()
|
||||
expect(onUnmapped).not.toHaveBeenCalled() // 422 mappee, pas de fallback
|
||||
})
|
||||
|
||||
it('delegue le fallback onUnmappedError pour une erreur non mappable et marque hasError', async () => {
|
||||
const { ribErrors, submitRows } = useClientFormErrors()
|
||||
const onUnmapped = vi.fn()
|
||||
|
||||
const hasError = await submitRows(
|
||||
[{ a: 0 }],
|
||||
ribErrors,
|
||||
async () => { throw { response: { status: 500, _data: {} } } },
|
||||
onUnmapped,
|
||||
)
|
||||
|
||||
expect(hasError).toBe(true)
|
||||
expect(onUnmapped).toHaveBeenCalledTimes(1)
|
||||
expect(ribErrors.value[0]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('saute les lignes filtrees par shouldSkip et renvoie false si tout passe', async () => {
|
||||
const { contactErrors, submitRows } = useClientFormErrors()
|
||||
const saved: number[] = []
|
||||
|
||||
const hasError = await submitRows(
|
||||
[{ skip: true }, { skip: false }],
|
||||
contactErrors,
|
||||
async (_row, index) => { saved.push(index) },
|
||||
vi.fn(),
|
||||
(row: { skip: boolean }) => row.skip,
|
||||
)
|
||||
|
||||
expect(saved).toEqual([1])
|
||||
expect(hasError).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -28,7 +28,11 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
|
||||
return Promise.reject(new Error('403 Forbidden'))
|
||||
}
|
||||
if (url === '/sites') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
|
||||
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
|
||||
}
|
||||
if (url === '/countries') {
|
||||
// Pays : value === label === name (l'adresse stocke le nom).
|
||||
return Promise.resolve({ member: [{ '@id': '/api/countries/1', code: 'FR', name: 'France' }] })
|
||||
}
|
||||
return Promise.resolve({
|
||||
member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }],
|
||||
@@ -40,9 +44,12 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
|
||||
await refs.loadCommon()
|
||||
|
||||
// Resilience : les referentiels OK sont peuples malgre l'echec de /categories.
|
||||
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: 'Chatellerault' }])
|
||||
// Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal).
|
||||
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
|
||||
expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
|
||||
expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
|
||||
// Pays : value = nom du pays (et non l'IRI).
|
||||
expect(refs.countries.value).toEqual([{ value: 'France', label: 'France' }])
|
||||
|
||||
// Seul le select en echec reste vide.
|
||||
expect(refs.categories.value).toEqual([])
|
||||
@@ -56,7 +63,7 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
|
||||
})
|
||||
}
|
||||
if (url === '/sites') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
|
||||
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
|
||||
}
|
||||
return Promise.resolve({ member: [] })
|
||||
})
|
||||
@@ -67,6 +74,26 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
|
||||
expect(refs.categories.value).toEqual([
|
||||
{ value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' },
|
||||
])
|
||||
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: 'Chatellerault' }])
|
||||
// Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal).
|
||||
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
|
||||
})
|
||||
|
||||
it('separe les categories CLIENT (formulaire) des categories ADRESSE (blocs adresse)', async () => {
|
||||
// Le mock distingue les deux appels /categories par leur filtre typeCode.
|
||||
mockGet.mockImplementation((url: string, query?: Record<string, unknown>) => {
|
||||
if (url === '/categories' && query?.typeCode === 'CLIENT') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/categories/1', code: 'SECTEUR', name: 'Secteur' }] })
|
||||
}
|
||||
if (url === '/categories' && query?.typeCode === 'ADRESSE') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/categories/9', code: 'SIEGE', name: 'Siège' }] })
|
||||
}
|
||||
return Promise.resolve({ member: [] })
|
||||
})
|
||||
|
||||
const refs = useClientReferentials()
|
||||
await refs.loadCommon()
|
||||
|
||||
expect(refs.categories.value).toEqual([{ value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' }])
|
||||
expect(refs.addressCategories.value).toEqual([{ value: '/api/categories/9', label: 'Siège', code: 'SIEGE' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mocks des composables auto-importes par Nuxt (indisponibles sous happy-dom).
|
||||
const mockGet = vi.hoisted(() => vi.fn())
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useApi', () => ({
|
||||
get: mockGet,
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: mockPatch,
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
|
||||
const { useSupplier } = await import('../useSupplier')
|
||||
|
||||
const SAMPLE = { '@id': '/api/suppliers/85', id: 85, companyName: 'DOD59393F 862875', isArchived: false }
|
||||
|
||||
describe('useSupplier', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
mockPatch.mockReset()
|
||||
mockGet.mockResolvedValue(SAMPLE)
|
||||
mockPatch.mockResolvedValue({ ...SAMPLE, isArchived: true })
|
||||
})
|
||||
|
||||
it('charge le detail via GET /suppliers/{id} en Hydra, sans toast', async () => {
|
||||
const { supplier, load } = useSupplier(85)
|
||||
await load()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/suppliers/85',
|
||||
{},
|
||||
expect.objectContaining({
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
}),
|
||||
)
|
||||
expect(supplier.value).toEqual(SAMPLE)
|
||||
})
|
||||
|
||||
it('bascule loading pendant le chargement et le retombe a false', async () => {
|
||||
const { loading, load } = useSupplier(85)
|
||||
const promise = load()
|
||||
expect(loading.value).toBe(true)
|
||||
await promise
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('marque error et laisse supplier null si le GET echoue (404...)', async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error('not found'))
|
||||
const { supplier, error, load } = useSupplier(99)
|
||||
await load()
|
||||
expect(error.value).toBe(true)
|
||||
expect(supplier.value).toBeNull()
|
||||
})
|
||||
|
||||
it('archive() PATCHe { isArchived: true } sans toast puis RECHARGE le detail complet', async () => {
|
||||
// 1er GET = chargement initial, 2e GET = rechargement post-archivage.
|
||||
mockGet.mockResolvedValueOnce(SAMPLE)
|
||||
mockGet.mockResolvedValueOnce({ ...SAMPLE, isArchived: true })
|
||||
const { supplier, load, archive } = useSupplier(85)
|
||||
await load()
|
||||
await archive()
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/suppliers/85',
|
||||
{ isArchived: true },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
// Le detail est re-fetch (le PATCH ne renvoie pas l'embed complet).
|
||||
expect(mockGet).toHaveBeenCalledTimes(2)
|
||||
expect(supplier.value?.isArchived).toBe(true)
|
||||
})
|
||||
|
||||
it('restore() PATCHe { isArchived: false } (payload isArchived SEUL)', async () => {
|
||||
const { load, restore } = useSupplier(85)
|
||||
await load()
|
||||
await restore()
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/suppliers/85',
|
||||
{ isArchived: false },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('propage l\'erreur (ex: 403 sans permission archive, 409 conflit homonyme) au lieu de l\'avaler', async () => {
|
||||
const forbidden = { response: { status: 403 } }
|
||||
mockPatch.mockRejectedValueOnce(forbidden)
|
||||
const { load, archive } = useSupplier(85)
|
||||
await load()
|
||||
await expect(archive()).rejects.toBe(forbidden)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter
|
||||
// les appels de chargement des referentiels et controler les reponses Hydra.
|
||||
const mockGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({ get: mockGet }))
|
||||
|
||||
const { useSupplierReferentials } = await import('../useSupplierReferentials')
|
||||
|
||||
describe('useSupplierReferentials', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
mockGet.mockResolvedValue({ member: [] })
|
||||
})
|
||||
|
||||
it('charge les categories filtrees sur le type FOURNISSEUR (RG-2.10)', async () => {
|
||||
await useSupplierReferentials().loadCommon()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/categories',
|
||||
expect.objectContaining({ pagination: 'false', typeCode: 'FOURNISSEUR' }),
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('charge les categories d\'adresse filtrees sur le type ADRESSE', async () => {
|
||||
await useSupplierReferentials().loadCommon()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/categories',
|
||||
expect.objectContaining({ pagination: 'false', typeCode: 'ADRESSE' }),
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('mappe les categories en options { value: IRI, label: name, code }', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/categories') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/categories/9', code: 'NEGOCIANT', name: 'Négociant' }] })
|
||||
}
|
||||
return Promise.resolve({ member: [] })
|
||||
})
|
||||
|
||||
const refs = useSupplierReferentials()
|
||||
await refs.loadCommon()
|
||||
|
||||
expect(refs.categories.value).toEqual([{ value: '/api/categories/9', label: 'Négociant', code: 'NEGOCIANT' }])
|
||||
})
|
||||
|
||||
it('ne charge ni distributeurs ni courtiers (absents du modele fournisseur)', async () => {
|
||||
await useSupplierReferentials().loadCommon()
|
||||
|
||||
const urls = mockGet.mock.calls.map(c => c[0])
|
||||
expect(urls).not.toContain('/clients')
|
||||
expect(urls).toEqual(
|
||||
expect.arrayContaining(['/categories', '/sites', '/tva_modes', '/payment_delays', '/payment_types', '/banks']),
|
||||
)
|
||||
})
|
||||
|
||||
it('reste resilient : un referentiel en echec n\'empeche pas les autres', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/categories') return Promise.reject(new Error('403'))
|
||||
if (url === '/banks') return Promise.resolve({ member: [{ '@id': '/api/banks/1', code: 'SG', label: 'Société Générale' }] })
|
||||
return Promise.resolve({ member: [] })
|
||||
})
|
||||
|
||||
const refs = useSupplierReferentials()
|
||||
await refs.loadCommon()
|
||||
|
||||
expect(refs.categories.value).toEqual([])
|
||||
expect(refs.banks.value).toEqual([{ value: '/api/banks/1', label: 'Société Générale' }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { HydraCollection } from '~/shared/utils/api'
|
||||
import type { Supplier } from '../useSuppliersRepository'
|
||||
|
||||
// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter
|
||||
// les appels declenches par usePaginatedList (que useSuppliersRepository enveloppe)
|
||||
// et controler les reponses. Meme pattern que useClientsRepository.spec.ts.
|
||||
const mockGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({
|
||||
get: mockGet,
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
|
||||
// Import APRES le stub pour que useApi soit bien resolu au top-level du module.
|
||||
const { useSuppliersRepository } = await import('../useSuppliersRepository')
|
||||
|
||||
/** Envelope Hydra minimale (la liste reelle des membres importe peu ici). */
|
||||
function makeHydra(total: number): HydraCollection<Supplier> {
|
||||
return { totalItems: total, member: [] }
|
||||
}
|
||||
|
||||
describe('useSuppliersRepository', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
// 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 () => {
|
||||
const repo = useSuppliersRepository()
|
||||
await repo.fetch()
|
||||
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/suppliers',
|
||||
{ page: 1, itemsPerPage: 25 },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('pousse les filtres du drawer (categories multi, sites, archives inclus) et retombe en page 1', async () => {
|
||||
const repo = useSuppliersRepository()
|
||||
await repo.fetch()
|
||||
await repo.goToPage(2)
|
||||
expect(repo.currentPage.value).toBe(2)
|
||||
|
||||
await repo.setFilters(
|
||||
{
|
||||
search: 'acme',
|
||||
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
|
||||
'siteId[]': ['86', '17'],
|
||||
archivedOnly: true,
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
|
||||
expect(repo.currentPage.value).toBe(1)
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/suppliers',
|
||||
{
|
||||
search: 'acme',
|
||||
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
|
||||
'siteId[]': ['86', '17'],
|
||||
archivedOnly: true,
|
||||
page: 1,
|
||||
itemsPerPage: 25,
|
||||
},
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('repasse a une query propre apres reinitialisation des filtres', async () => {
|
||||
const repo = useSuppliersRepository()
|
||||
await repo.setFilters({ search: 'acme', archivedOnly: true }, { replace: true })
|
||||
await repo.setFilters({}, { replace: true })
|
||||
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/suppliers',
|
||||
{ page: 1, itemsPerPage: 25 },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation'
|
||||
import type { ClientDetail } from '~/modules/commercial/utils/forms/clientConsultation'
|
||||
|
||||
/**
|
||||
* Chargement et actions d'archivage d'un client unique (ecran « Consultation
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Composable d'erreurs partage des ecrans client (creation + edition, M1
|
||||
* Commercial). Factorise le cablage identique entre `clients/new.vue` et
|
||||
* `clients/[id]/edit.vue` (suggestion de revue ERP-101) :
|
||||
* - un `useFormErrors` par groupe scalaire (Principal / Information /
|
||||
* Comptabilite) : violations 422 affichees inline sous chaque champ ;
|
||||
* - un tableau d'erreurs PAR LIGNE pour chaque collection (contacts /
|
||||
* adresses / RIB), aligne sur l'index du `v-for`.
|
||||
*
|
||||
* `mapRowError` ne toaste PAS lui-meme : il retourne un booleen (true = mappe
|
||||
* inline). Chaque page conserve ainsi son propre fallback dans le `catch`
|
||||
* (toast generique en creation, `showError` en edition) sans imposer un
|
||||
* comportement commun.
|
||||
*/
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { mapViolationsToRecord } from '~/shared/utils/api'
|
||||
|
||||
export function useClientFormErrors() {
|
||||
const mainErrors = useFormErrors()
|
||||
const informationErrors = useFormErrors()
|
||||
const accountingErrors = useFormErrors()
|
||||
const contactErrors = ref<Record<string, string>[]>([])
|
||||
const addressErrors = ref<Record<string, string>[]>([])
|
||||
const ribErrors = ref<Record<string, string>[]>([])
|
||||
|
||||
/**
|
||||
* Mappe l'erreur d'une ligne de collection sur le tableau cible (par index).
|
||||
* 422 avec violations exploitables → erreurs inline sous les champs de la
|
||||
* ligne + retourne true. Sinon → ne touche pas la cible et retourne false
|
||||
* (le caller decide du fallback toast).
|
||||
*/
|
||||
function mapRowError(
|
||||
error: unknown,
|
||||
target: Ref<Record<string, string>[]>,
|
||||
index: number,
|
||||
): boolean {
|
||||
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
|
||||
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
|
||||
if (Object.keys(mapped).length > 0) {
|
||||
target.value[index] = mapped
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Soumet TOUS les blocs d'une collection (contacts / adresses / RIB) en
|
||||
* collectant les erreurs par index : on n'arrete PAS au premier bloc en echec
|
||||
* (decision ERP-110 / ERP-101). Reinitialise le tableau d'erreurs cible, tente
|
||||
* chaque ligne via `saveRow`, mappe les 422 inline (mapRowError) ou delegue le
|
||||
* fallback a `onUnmappedError`. `shouldSkip` permet d'ignorer les blocs vides
|
||||
* (non remplis). Retourne true si au moins un bloc a echoue (le caller ne valide
|
||||
* alors pas l'onglet et n'affiche pas de toast succes).
|
||||
*/
|
||||
async function submitRows<T>(
|
||||
rows: T[],
|
||||
target: Ref<Record<string, string>[]>,
|
||||
saveRow: (row: T, index: number) => Promise<void>,
|
||||
onUnmappedError: (error: unknown, index: number) => void,
|
||||
shouldSkip?: (row: T, index: number) => boolean,
|
||||
): Promise<boolean> {
|
||||
target.value = []
|
||||
let hasError = false
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
// L'index reste borne par rows.length : la ligne existe forcement.
|
||||
const row = rows[index] as T
|
||||
if (shouldSkip?.(row, index)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await saveRow(row, index)
|
||||
}
|
||||
catch (error) {
|
||||
if (!mapRowError(error, target, index)) {
|
||||
onUnmappedError(error, index)
|
||||
}
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
|
||||
return hasError
|
||||
}
|
||||
|
||||
return {
|
||||
mainErrors,
|
||||
informationErrors,
|
||||
accountingErrors,
|
||||
contactErrors,
|
||||
addressErrors,
|
||||
ribErrors,
|
||||
mapRowError,
|
||||
submitRows,
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { ref } from 'vue'
|
||||
/**
|
||||
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran
|
||||
* « Ajouter un client » : categories, sites, modes de TVA, delais et types de
|
||||
* reglement, banques, et les listes distributeurs / courtiers.
|
||||
* reglement, banques, pays, et les listes distributeurs / courtiers.
|
||||
*
|
||||
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
|
||||
* `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec
|
||||
@@ -45,6 +45,7 @@ interface CategoryMember extends HydraMember {
|
||||
|
||||
interface SiteMember extends HydraMember {
|
||||
name: string
|
||||
postalCode: string
|
||||
}
|
||||
|
||||
interface ReferentialMember extends HydraMember {
|
||||
@@ -56,17 +57,26 @@ interface ClientMember extends HydraMember {
|
||||
companyName: string
|
||||
}
|
||||
|
||||
interface CountryMember extends HydraMember {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||
|
||||
export function useClientReferentials() {
|
||||
const api = useApi()
|
||||
|
||||
const categories = ref<CategoryOption[]>([])
|
||||
// Taxonomie dediee aux blocs adresse (type ADRESSE), distincte des categories
|
||||
// CLIENT du formulaire principal.
|
||||
const addressCategories = ref<CategoryOption[]>([])
|
||||
const sites = ref<RefOption[]>([])
|
||||
const tvaModes = ref<RefOption[]>([])
|
||||
const paymentDelays = ref<RefOption[]>([])
|
||||
const paymentTypes = ref<PaymentTypeOption[]>([])
|
||||
const banks = ref<RefOption[]>([])
|
||||
const countries = ref<RefOption[]>([])
|
||||
const distributors = ref<ClientOption[]>([])
|
||||
const brokers = ref<ClientOption[]>([])
|
||||
|
||||
@@ -98,10 +108,18 @@ export function useClientReferentials() {
|
||||
*/
|
||||
async function loadCommon(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
fetchAll<CategoryMember>('/categories')
|
||||
// Taxonomie multi-types (ERP-84) : un client ne porte que des categories
|
||||
// de type CLIENT (pas FOURNISSEUR) -> on filtre la collection cote API.
|
||||
fetchAll<CategoryMember>('/categories', { typeCode: 'CLIENT' })
|
||||
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
|
||||
// Categories des blocs adresse : taxonomie dediee type ADRESSE.
|
||||
fetchAll<CategoryMember>('/categories', { typeCode: 'ADRESSE' })
|
||||
.then((cats) => { addressCategories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
|
||||
fetchAll<SiteMember>('/sites')
|
||||
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name })) }),
|
||||
// Libelle = numero de departement (2 premiers chiffres du code
|
||||
// postal du site), ex: 86100 -> « 86 ». Le code postal est deja
|
||||
// expose par /sites (groupe site:read) — aucune colonne a ajouter.
|
||||
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
|
||||
fetchAll<ReferentialMember>('/tva_modes')
|
||||
.then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }),
|
||||
fetchAll<ReferentialMember>('/payment_delays')
|
||||
@@ -110,6 +128,12 @@ export function useClientReferentials() {
|
||||
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
|
||||
fetchAll<ReferentialMember>('/banks')
|
||||
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }),
|
||||
// Pays (ERP-116) : la valeur d'option est le NOM du pays (et non l'IRI),
|
||||
// car l'adresse stocke `country` en chaine libre (« France »...). On
|
||||
// conserve ainsi la compatibilite avec les adresses existantes sans FK
|
||||
// ni migration de donnees a ce stade. value === label.
|
||||
fetchAll<CountryMember>('/countries')
|
||||
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -133,11 +157,13 @@ export function useClientReferentials() {
|
||||
|
||||
return {
|
||||
categories,
|
||||
addressCategories,
|
||||
sites,
|
||||
tvaModes,
|
||||
paymentDelays,
|
||||
paymentTypes,
|
||||
banks,
|
||||
countries,
|
||||
distributors,
|
||||
brokers,
|
||||
loadCommon,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ref } from 'vue'
|
||||
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||
|
||||
/**
|
||||
* Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation
|
||||
* fournisseur », ERP-95). Miroir de `useClient` (M1). Lit le detail embarque via
|
||||
* `GET /api/suppliers/{id}` (contacts / adresses / ribs sous `supplier:item:read` /
|
||||
* `supplier:read:accounting`) et expose les bascules d'archivage (PATCH `isArchived`
|
||||
* SEUL — tout autre champ => 422).
|
||||
*
|
||||
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload
|
||||
* Hydra complet (sans lui, API Platform 4 renvoie une representation reduite).
|
||||
*
|
||||
* Etat 100 % local a l'instance (refs) — aucune persistance URL. Les erreurs
|
||||
* d'archivage/restauration (notamment le 409 d'homonyme actif a la restauration)
|
||||
* sont PROPAGEES a l'appelant, qui decide du toast a afficher.
|
||||
*/
|
||||
export function useSupplier(id: number | string) {
|
||||
const api = useApi()
|
||||
|
||||
const supplier = ref<SupplierDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
|
||||
/** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
|
||||
function fetchDetail(): Promise<SupplierDetail> {
|
||||
return api.get<SupplierDetail>(
|
||||
`/suppliers/${id}`,
|
||||
{},
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
}
|
||||
|
||||
/** Charge le detail du fournisseur. En cas d'echec : `error = true`, `supplier = null`. */
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
supplier.value = await fetchDetail()
|
||||
}
|
||||
catch {
|
||||
error.value = true
|
||||
supplier.value = null
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bascule l'archivage (PATCH `isArchived` SEUL — tout autre champ => 422),
|
||||
* puis RECHARGE le detail complet : la reponse du PATCH ne porte que le groupe
|
||||
* `supplier:read` (ni l'embed contacts/adresses/ribs ni les libelles des
|
||||
* referentiels comptables), un simple merge laisserait l'affichage incoherent.
|
||||
* Toute erreur (notamment le 409 d'homonyme actif a la restauration) est
|
||||
* propagee a l'appelant AVANT le rechargement.
|
||||
*/
|
||||
async function setArchived(isArchived: boolean): Promise<void> {
|
||||
await api.patch(`/suppliers/${id}`, { isArchived }, { toast: false })
|
||||
supplier.value = await fetchDetail()
|
||||
}
|
||||
|
||||
return {
|
||||
supplier,
|
||||
loading,
|
||||
error,
|
||||
load,
|
||||
archive: () => setArchived(true),
|
||||
restore: () => setArchived(false),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Composable d'erreurs partage des ecrans fournisseur (creation + edition, M2
|
||||
* Commercial). Miroir de `useClientFormErrors` (M1) :
|
||||
* - un `useFormErrors` par groupe scalaire (Principal / Information /
|
||||
* Comptabilite) : violations 422 affichees inline sous chaque champ ;
|
||||
* - un tableau d'erreurs PAR LIGNE pour chaque collection (contacts /
|
||||
* adresses / RIB), aligne sur l'index du `v-for`.
|
||||
*
|
||||
* `mapRowError` ne toaste PAS lui-meme : il retourne un booleen (true = mappe
|
||||
* inline). Chaque page conserve ainsi son propre fallback dans le `catch`.
|
||||
*/
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { mapViolationsToRecord } from '~/shared/utils/api'
|
||||
|
||||
export function useSupplierFormErrors() {
|
||||
const mainErrors = useFormErrors()
|
||||
const informationErrors = useFormErrors()
|
||||
const accountingErrors = useFormErrors()
|
||||
const contactErrors = ref<Record<string, string>[]>([])
|
||||
const addressErrors = ref<Record<string, string>[]>([])
|
||||
const ribErrors = ref<Record<string, string>[]>([])
|
||||
|
||||
/**
|
||||
* Mappe l'erreur d'une ligne de collection sur le tableau cible (par index).
|
||||
* 422 avec violations exploitables → erreurs inline sous les champs de la
|
||||
* ligne + retourne true. Sinon → ne touche pas la cible et retourne false.
|
||||
*/
|
||||
function mapRowError(
|
||||
error: unknown,
|
||||
target: Ref<Record<string, string>[]>,
|
||||
index: number,
|
||||
): boolean {
|
||||
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
|
||||
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
|
||||
if (Object.keys(mapped).length > 0) {
|
||||
target.value[index] = mapped
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Soumet TOUS les blocs d'une collection (contacts / adresses / RIB) en
|
||||
* collectant les erreurs par index : on n'arrete PAS au premier bloc en echec
|
||||
* (decision ERP-110 / ERP-101). Reinitialise le tableau d'erreurs cible, tente
|
||||
* chaque ligne via `saveRow`, mappe les 422 inline (mapRowError) ou delegue le
|
||||
* fallback a `onUnmappedError`. `shouldSkip` permet d'ignorer les blocs vides.
|
||||
* Retourne true si au moins un bloc a echoue.
|
||||
*/
|
||||
async function submitRows<T>(
|
||||
rows: T[],
|
||||
target: Ref<Record<string, string>[]>,
|
||||
saveRow: (row: T, index: number) => Promise<void>,
|
||||
onUnmappedError: (error: unknown, index: number) => void,
|
||||
shouldSkip?: (row: T, index: number) => boolean,
|
||||
): Promise<boolean> {
|
||||
target.value = []
|
||||
let hasError = false
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
const row = rows[index] as T
|
||||
if (shouldSkip?.(row, index)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await saveRow(row, index)
|
||||
}
|
||||
catch (error) {
|
||||
if (!mapRowError(error, target, index)) {
|
||||
onUnmappedError(error, index)
|
||||
}
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
|
||||
return hasError
|
||||
}
|
||||
|
||||
return {
|
||||
mainErrors,
|
||||
informationErrors,
|
||||
accountingErrors,
|
||||
contactErrors,
|
||||
addressErrors,
|
||||
ribErrors,
|
||||
mapRowError,
|
||||
submitRows,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran
|
||||
* « Ajouter un fournisseur » : categories (type FOURNISSEUR), sites, modes de TVA,
|
||||
* delais et types de reglement, banques. Miroir de `useClientReferentials` (M1).
|
||||
*
|
||||
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
|
||||
* `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec
|
||||
* l'en-tete `Accept: application/ld+json` impose par API Platform 4 pour obtenir
|
||||
* l'enveloppe Hydra (`member`). Les valeurs d'option sont les IRI Hydra (`@id`)
|
||||
* renvoyees telles quelles dans les payloads POST/PATCH (relations M:1 / M:N).
|
||||
*
|
||||
* Difference M2 : pas de distributeurs/courtiers (absents du modele fournisseur).
|
||||
*
|
||||
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
|
||||
*/
|
||||
|
||||
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
|
||||
export interface RefOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Option de type de reglement enrichie de son code stable (RG-2.07 / RG-2.08). */
|
||||
export interface PaymentTypeOption extends RefOption {
|
||||
code: string
|
||||
}
|
||||
|
||||
/** Option de categorie enrichie de son code stable. */
|
||||
export interface CategoryOption extends RefOption {
|
||||
code: string
|
||||
}
|
||||
|
||||
interface HydraMember {
|
||||
'@id': string
|
||||
}
|
||||
|
||||
interface CategoryMember extends HydraMember {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface SiteMember extends HydraMember {
|
||||
name: string
|
||||
postalCode: string
|
||||
}
|
||||
|
||||
interface ReferentialMember extends HydraMember {
|
||||
code: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface CountryMember extends HydraMember {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||
|
||||
export function useSupplierReferentials() {
|
||||
const api = useApi()
|
||||
|
||||
const categories = ref<CategoryOption[]>([])
|
||||
// Taxonomie dediee aux blocs adresse (type ADRESSE), distincte des categories
|
||||
// FOURNISSEUR du formulaire principal.
|
||||
const addressCategories = ref<CategoryOption[]>([])
|
||||
const sites = ref<RefOption[]>([])
|
||||
const tvaModes = ref<RefOption[]>([])
|
||||
const paymentDelays = ref<RefOption[]>([])
|
||||
const paymentTypes = ref<PaymentTypeOption[]>([])
|
||||
const banks = ref<RefOption[]>([])
|
||||
const countries = ref<RefOption[]>([])
|
||||
|
||||
/** Recupere une collection complete (pagination desactivee) en Hydra. */
|
||||
async function fetchAll<T extends HydraMember>(
|
||||
url: string,
|
||||
query: Record<string, string | string[]> = {},
|
||||
): Promise<T[]> {
|
||||
const res = await api.get<{ member?: T[] }>(
|
||||
url,
|
||||
{ pagination: 'false', ...query },
|
||||
{ headers: LD_JSON_HEADERS, toast: false },
|
||||
)
|
||||
return res.member ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge en parallele les referentiels communs.
|
||||
*
|
||||
* Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole.
|
||||
* Necessaire pour les roles metier qui n'ont pas toutes les permissions de
|
||||
* lecture (ex. Compta a `commercial.suppliers.view` mais pas forcement
|
||||
* `catalog.categories.view` ni `sites.view`). Un referentiel en echec reste
|
||||
* simplement vide.
|
||||
*/
|
||||
async function loadCommon(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
// Taxonomie multi-types (ERP-84) : un fournisseur ne porte que des
|
||||
// categories de type FOURNISSEUR (RG-2.10) -> on filtre cote API.
|
||||
fetchAll<CategoryMember>('/categories', { typeCode: 'FOURNISSEUR' })
|
||||
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
|
||||
// Categories des blocs adresse : taxonomie dediee type ADRESSE.
|
||||
fetchAll<CategoryMember>('/categories', { typeCode: 'ADRESSE' })
|
||||
.then((cats) => { addressCategories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
|
||||
fetchAll<SiteMember>('/sites')
|
||||
// Libelle = numero de departement (2 premiers chiffres du code
|
||||
// postal du site), ex: 86100 -> « 86 ».
|
||||
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
|
||||
fetchAll<ReferentialMember>('/tva_modes')
|
||||
.then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }),
|
||||
fetchAll<ReferentialMember>('/payment_delays')
|
||||
.then((delays) => { paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label })) }),
|
||||
fetchAll<ReferentialMember>('/payment_types')
|
||||
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
|
||||
fetchAll<ReferentialMember>('/banks')
|
||||
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }),
|
||||
// Pays (ERP-116) : la valeur d'option est le NOM du pays (et non l'IRI),
|
||||
// car l'adresse stocke `country` en chaine libre (« France »...). On
|
||||
// conserve ainsi la compatibilite avec les adresses existantes sans FK
|
||||
// ni migration de donnees a ce stade. value === label. Aligne sur les
|
||||
// clients (`useClientReferentials`) pour une liste de pays identique.
|
||||
fetchAll<CountryMember>('/countries')
|
||||
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
addressCategories,
|
||||
sites,
|
||||
tvaModes,
|
||||
paymentDelays,
|
||||
paymentTypes,
|
||||
banks,
|
||||
countries,
|
||||
loadCommon,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||
|
||||
/**
|
||||
* Site Starseed rattache a une adresse du fournisseur, tel qu'embarque en LISTE
|
||||
* (groupe site:read) pour la colonne « Site » du Repertoire (badges colores).
|
||||
* Agrege des adresses cote back via Supplier::getSites() (cf. spec-back M2).
|
||||
*/
|
||||
export interface SupplierSite {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorie (type FOURNISSEUR) rattachee au fournisseur, embarquee en LISTE
|
||||
* (groupe category:read). La colonne « Catégories » affiche le `name` (et non le
|
||||
* `code` comme au M1 clients — decision spec-front M2 § Datatable).
|
||||
*/
|
||||
export interface SupplierCategory {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue MINIMALE d'un fournisseur pour le Repertoire (datatable). Volontairement
|
||||
* partielle : seuls les champs des colonnes + l'id (navigation) sont types ici.
|
||||
* Le detail complet (onglets) est hors perimetre de cet ecran (ERP-93).
|
||||
*/
|
||||
export interface Supplier {
|
||||
id: number
|
||||
companyName: string
|
||||
categories: SupplierCategory[]
|
||||
sites: SupplierSite[]
|
||||
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
|
||||
updatedAt: string | null
|
||||
isArchived: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Repertoire fournisseurs (ERP-93) — simple enveloppe de `usePaginatedList<Supplier>`
|
||||
* sur la ressource `/suppliers` (RG-13 : pagination serveur obligatoire ; jamais
|
||||
* de chargement integral en memoire). Miroir de `useClientsRepository` (M1).
|
||||
*
|
||||
* Les filtres (recherche, categories, sites, archives) sont pilotes par la page
|
||||
* via `setFilters` du composable partage — la remise en page 1 est garantie.
|
||||
* Cocher « Voir les archivés » envoie `archivedOnly=true` → seules les archives
|
||||
* sont listees (aligne sur Client).
|
||||
*
|
||||
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
|
||||
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
|
||||
* `usePaginatedList`. Aucun reset au logout a gerer.
|
||||
*/
|
||||
export function useSuppliersRepository() {
|
||||
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
|
||||
return usePaginatedList<Supplier>({ url: '/suppliers', defaultItemsPerPage: 25 })
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
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 autres specs commercial.
|
||||
const mockPush = vi.hoisted(() => vi.fn())
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
const mockCan = vi.hoisted(() => vi.fn())
|
||||
const mockSetFilters = vi.hoisted(() => vi.fn())
|
||||
const mockFetch = vi.hoisted(() => vi.fn())
|
||||
const mockToastError = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('useHead', () => undefined)
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
|
||||
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||
|
||||
// Le repository est lui aussi un auto-import : on controle items + setFilters.
|
||||
vi.stubGlobal('useSuppliersRepository', () => ({
|
||||
items: ref([
|
||||
{
|
||||
id: 7,
|
||||
companyName: 'ACME',
|
||||
categories: [{ code: 'NEG', name: 'Négociant' }],
|
||||
sites: [{ id: 86, name: '86', color: '#123456' }],
|
||||
updatedAt: '2026-01-15T10:00:00+00:00',
|
||||
},
|
||||
]),
|
||||
totalItems: ref(1),
|
||||
currentPage: ref(1),
|
||||
itemsPerPage: ref(10),
|
||||
itemsPerPageOptions: ref([10, 25, 50]),
|
||||
fetch: mockFetch,
|
||||
goToPage: vi.fn(),
|
||||
setItemsPerPage: vi.fn(),
|
||||
setFilters: mockSetFilters,
|
||||
}))
|
||||
|
||||
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
|
||||
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
|
||||
globalThis.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
|
||||
const SuppliersIndex = (await import('../suppliers/index.vue')).default
|
||||
|
||||
// ── Stubs de composants ──────────────────────────────────────────────────────
|
||||
const ButtonStub = defineComponent({
|
||||
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||
emits: ['click'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||
},
|
||||
})
|
||||
|
||||
const DataTableStub = defineComponent({
|
||||
props: { items: { type: Array, default: () => [] } },
|
||||
emits: ['row-click', 'update:page', 'update:per-page'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('div', { 'data-testid': 'datatable' },
|
||||
(props.items as Array<{ id: number }>).map(it =>
|
||||
h('tr', { 'data-row-id': it.id, onClick: () => emit('row-click', it) }),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const DrawerStub = defineComponent({
|
||||
props: { modelValue: { type: Boolean, default: false } },
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()])
|
||||
},
|
||||
})
|
||||
|
||||
const SlotStub = defineComponent({ setup(_, { slots }) { return () => h('div', {}, slots.default?.()) } })
|
||||
|
||||
const PageHeaderStub = defineComponent({
|
||||
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
|
||||
})
|
||||
|
||||
const CheckboxStub = defineComponent({
|
||||
props: { id: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
|
||||
emits: ['update:model-value'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('input', {
|
||||
'type': 'checkbox',
|
||||
'data-id': props.id,
|
||||
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLInputElement).checked),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const InputTextStub = defineComponent({ setup() { return () => h('input') } })
|
||||
|
||||
function mountPage() {
|
||||
return mount(SuppliersIndex, {
|
||||
global: {
|
||||
stubs: {
|
||||
PageHeader: PageHeaderStub,
|
||||
MalioButton: ButtonStub,
|
||||
MalioDataTable: DataTableStub,
|
||||
MalioDrawer: DrawerStub,
|
||||
MalioAccordion: SlotStub,
|
||||
MalioAccordionItem: SlotStub,
|
||||
MalioInputText: InputTextStub,
|
||||
MalioCheckbox: CheckboxStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('Répertoire fournisseurs (page /suppliers)', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockReset()
|
||||
mockApiGet.mockReset().mockResolvedValue({ member: [] })
|
||||
mockCan.mockReset().mockReturnValue(true)
|
||||
mockSetFilters.mockReset()
|
||||
mockFetch.mockReset()
|
||||
mockToastError.mockReset()
|
||||
})
|
||||
|
||||
it('charge la liste au montage', async () => {
|
||||
mountPage()
|
||||
await flushPromises()
|
||||
expect(mockFetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
|
||||
mockCan.mockImplementation((perm: string) => perm === 'commercial.suppliers.manage')
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-label="commercial.suppliers.add"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
|
||||
mockCan.mockImplementation((perm: string) => perm === 'commercial.suppliers.view')
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-label="commercial.suppliers.add"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('navigue vers la consultation au clic sur une ligne', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
await wrapper.find('tr[data-row-id="7"]').trigger('click')
|
||||
expect(mockPush).toHaveBeenCalledWith('/suppliers/7')
|
||||
})
|
||||
|
||||
it('charge les categories de type FOURNISSEUR pour le filtre', async () => {
|
||||
mountPage()
|
||||
await flushPromises()
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
'/categories',
|
||||
expect.objectContaining({ pagination: 'false', typeCode: 'FOURNISSEUR' }),
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('appelle l\'export XLSX sur /suppliers/export.xlsx en blob', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-label="commercial.suppliers.export"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
'/suppliers/export.xlsx',
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ responseType: 'blob', toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('repercute le filtre « Voir les archivés » dans setFilters sans toucher l\'URL', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
// Coche « Voir les archivés » puis applique les filtres.
|
||||
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
|
||||
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
|
||||
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||
{ archivedOnly: true },
|
||||
{ replace: true },
|
||||
)
|
||||
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('badge filtres actifs + Réinitialiser vide l\'etat applique', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('input[data-id="filter-archived-only"]').setValue(true)
|
||||
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
|
||||
|
||||
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
|
||||
expect(wrapper.find('[data-label="commercial.suppliers.filters.title (1)"]').exists()).toBe(true)
|
||||
|
||||
// Réinitialiser → query propre (setFilters avec objet vide).
|
||||
await wrapper.find('[data-label="commercial.suppliers.filters.reset"]').trigger('click')
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true })
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete : retour repertoire + nom du client + actions (Modifier / Archiver|Restaurer). -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
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"
|
||||
/>
|
||||
<h1 class="text-[32px] font-bold text-m-primary">{{ headerTitle }}</h1>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
|
||||
|
||||
<!-- gap-12 = 48px : meme espacement que Ajouter / Filtres du repertoire. -->
|
||||
<div class="ml-auto flex items-center gap-12">
|
||||
@@ -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,100 +49,98 @@
|
||||
<!-- ── 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
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="client.lastName"
|
||||
:label="t('commercial.clients.form.main.lastName')"
|
||||
readonly
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="client.firstName"
|
||||
:label="t('commercial.clients.form.main.firstName')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
v-if="isFilled(categoryIris)"
|
||||
:model-value="categoryIris"
|
||||
:options="mainCategoryOptions"
|
||||
:label="t('commercial.clients.form.main.categories')"
|
||||
:display-tag="true"
|
||||
disabled
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-for="(phone, index) in mainPhones"
|
||||
:key="index"
|
||||
:model-value="phone"
|
||||
:label="index === 0 ? t('commercial.clients.form.main.phonePrimary') : t('commercial.clients.form.main.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
readonly
|
||||
/>
|
||||
<MalioInputEmail
|
||||
:model-value="client.email"
|
||||
:label="t('commercial.clients.form.main.email')"
|
||||
readonly
|
||||
/>
|
||||
<!-- Relation : masquee en consultation si aucune (ERP-193) ; en edition
|
||||
elle reste toujours visible (vide = « Aucun »). -->
|
||||
<MalioSelect
|
||||
v-if="relation.type"
|
||||
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')"
|
||||
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" 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)]">
|
||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
|
||||
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"
|
||||
group-class="row-span-2 pt-1 pb-1"
|
||||
text-input="h-full text-lg"
|
||||
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')"
|
||||
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')"
|
||||
disabled
|
||||
@@ -157,11 +156,10 @@
|
||||
:key="contact.id ?? index"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
readonly
|
||||
:last="index === contacts.length - 1"
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
<p v-if="contacts.length === 0" class="text-center text-black/60">
|
||||
{{ t('commercial.clients.consultation.emptyContacts') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -173,35 +171,39 @@
|
||||
:key="view.draft.id ?? index"
|
||||
:model-value="view.draft"
|
||||
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
||||
:last="index === addressViews.length - 1"
|
||||
:category-options="view.categoryOptions"
|
||||
:site-options="view.siteOptions"
|
||||
:site-options="allSiteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
readonly
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
<p v-if="addressViews.length === 0" class="text-center text-black/60">
|
||||
{{ t('commercial.clients.consultation.emptyAddresses') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 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-3 gap-x-[80px] gap-y-5">
|
||||
<!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
|
||||
<div class="pb-[20px]" :class="{ 'border-b border-black': ribs.length > 0 }">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.infoTitle') }}</h2>
|
||||
<div class="mt-6 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')"
|
||||
@@ -209,11 +211,13 @@
|
||||
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')"
|
||||
@@ -221,6 +225,7 @@
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="isFilled(accounting.paymentTypeIri)"
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="paymentTypeOptions"
|
||||
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||
@@ -238,38 +243,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB (0..n), lecture seule. -->
|
||||
<!-- Blocs RIB (0..n), lecture seule.
|
||||
Titre « RIB N », filet de separation sauf sous le dernier. -->
|
||||
<div
|
||||
v-for="(rib, index) in ribs"
|
||||
:key="rib.id ?? index"
|
||||
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
class="pb-[20px]"
|
||||
:class="{ 'border-b border-black': index !== ribs.length - 1 }"
|
||||
>
|
||||
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
|
||||
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
|
||||
<div class="mt-6 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><TabPlaceholderBlank /></template>
|
||||
<template #statistics><TabPlaceholderBlank /></template>
|
||||
<template #reports><TabPlaceholderBlank /></template>
|
||||
<template #exchanges><TabPlaceholderBlank /></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>
|
||||
|
||||
@@ -301,28 +309,31 @@
|
||||
</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 } from '~/modules/commercial/utils/clientFormRules'
|
||||
import { isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
import {
|
||||
canEditClient,
|
||||
categoryOptionsOf,
|
||||
clientConsultationVisibleTabs,
|
||||
contactOptionsOf,
|
||||
mapAccountingDraft,
|
||||
mapAddressView,
|
||||
mapContactToDraft,
|
||||
mapRibToDraft,
|
||||
paymentTypeCodeOf,
|
||||
referentialOptionOf,
|
||||
relationOf,
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
type ClientDetail,
|
||||
type SelectOption,
|
||||
} from '~/modules/commercial/utils/clientConsultation'
|
||||
import { formatPhoneFR } from '~/shared/utils/phone'
|
||||
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masques d'affichage (purement visuels, la donnee reste celle du serveur).
|
||||
const PHONE_MASK = '## ## ## ## ##'
|
||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -330,6 +341,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can, canAny } = usePermissions()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Gating de la route : la consultation exige `view`. Usine (sans view) est
|
||||
// redirige vers le repertoire (lui-meme protege). Cf. matrice § 2.7.
|
||||
@@ -354,13 +366,6 @@ const headerTitle = computed(() => client.value?.companyName ?? t('commercial.cl
|
||||
const relation = computed(() => (client.value ? relationOf(client.value) : { type: null, name: null }))
|
||||
const categoryIris = computed(() => (client.value?.categories ?? []).map(c => c['@id']))
|
||||
|
||||
// Telephones du formulaire principal, formates XX XX XX XX XX (RG d'affichage).
|
||||
const mainPhones = computed(() =>
|
||||
[client.value?.phonePrimary, client.value?.phoneSecondary]
|
||||
.filter((p): p is string => Boolean(p))
|
||||
.map(formatPhoneFR),
|
||||
)
|
||||
|
||||
const information = computed(() => ({
|
||||
description: client.value?.description ?? null,
|
||||
competitors: client.value?.competitors ?? null,
|
||||
@@ -372,10 +377,28 @@ const information = computed(() => ({
|
||||
directorName: client.value?.directorName ?? null,
|
||||
}))
|
||||
|
||||
const contacts = computed(() => (client.value?.contacts ?? []).map(mapContactToDraft))
|
||||
// Chaque bloc reste visible meme vide en consultation : si la collection est
|
||||
// vide, on affiche un bloc vierge en lecture seule (pas de message « Aucun … »).
|
||||
const contacts = computed(() => {
|
||||
const list = (client.value?.contacts ?? []).map(mapContactToDraft)
|
||||
return list.length ? list : [emptyContact()]
|
||||
})
|
||||
// Vue par adresse : brouillon + options (sites/categories) propres a l'adresse.
|
||||
const addressViews = computed(() => (client.value?.addresses ?? []).map(mapAddressView))
|
||||
const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft))
|
||||
const addressViews = computed(() => {
|
||||
const views = (client.value?.addresses ?? []).map(mapAddressView)
|
||||
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
|
||||
})
|
||||
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
|
||||
// client n'en a pas. Pas de bloc vierge fantome en consultation.
|
||||
// ERP-121 : un client peut desormais conserver des RIB « dormants » apres etre
|
||||
// repasse hors-LCR (on ne les supprime plus). En consultation, decision metier =
|
||||
// on les masque TOTALEMENT : on n'affiche les RIB que si le type de reglement
|
||||
// courant est LCR (le `code` est embarque sous client:read:accounting).
|
||||
const ribs = computed(() =>
|
||||
isRibRequiredForPaymentType(paymentTypeCodeOf(client.value?.paymentType))
|
||||
? (client.value?.ribs ?? []).map(mapRibToDraft)
|
||||
: [],
|
||||
)
|
||||
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
|
||||
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
|
||||
|
||||
@@ -385,15 +408,35 @@ const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as Clie
|
||||
const mainCategoryOptions = computed(() => categoryOptionsOf(client.value?.categories))
|
||||
const contactOptions = computed(() => contactOptionsOf(client.value?.contacts))
|
||||
|
||||
// Liste COMPLETE des sites disponibles, issue de /api/me (groupe me:read — donc
|
||||
// pas de 403 pour les roles metier, contrairement a GET /sites). Libelle = numero
|
||||
// de departement (2 premiers chiffres du code postal). Permet d'afficher TOUJOURS
|
||||
// toutes les cases « Sites » (86 / 17 / 82) dans le bloc adresse, meme celles non
|
||||
// rattachees a l'adresse consultee (les rattachees restent cochees via siteIris).
|
||||
const allSiteOptions = computed<SelectOption[]>(() =>
|
||||
(authStore.user?.sites ?? []).map(s => ({
|
||||
value: `/api/sites/${s.id}`,
|
||||
label: (s.postalCode ?? '').slice(0, 2),
|
||||
})),
|
||||
)
|
||||
|
||||
const relationOptions = computed<SelectOption[]>(() => [
|
||||
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
|
||||
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
|
||||
])
|
||||
|
||||
const countryOptions: SelectOption[] = [
|
||||
{ value: 'France', label: 'France' },
|
||||
{ value: 'Espagne', label: 'Espagne' },
|
||||
]
|
||||
// Pays (ERP-116) : options construites depuis l'EMBED des adresses (jamais via
|
||||
// GET /countries, sur le meme principe que les autres selects de consultation
|
||||
// — en 403 pour les roles metier non-admin). Valeur = nom du pays stocke tel
|
||||
// quel dans l'adresse, donc value === label ; suffit a afficher le libelle en
|
||||
// lecture seule.
|
||||
const countryOptions = computed<SelectOption[]>(() =>
|
||||
[...new Set(
|
||||
(client.value?.addresses ?? [])
|
||||
.map(a => a.country)
|
||||
.filter((c): c is string => !!c),
|
||||
)].map(c => ({ value: c, label: c })),
|
||||
)
|
||||
|
||||
// Selects comptables : libelle issu de l'embed (option unique ou vide).
|
||||
const tvaModeOptions = computed(() => referentialOptionOf(client.value?.tvaMode))
|
||||
@@ -402,9 +445,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',
|
||||
@@ -417,21 +462,35 @@ 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],
|
||||
})))
|
||||
|
||||
const activeTab = ref('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 {
|
||||
router.push('/clients')
|
||||
}
|
||||
|
||||
/** Bascule en edition en conservant l'onglet courant (via history.state). */
|
||||
function goEdit(): void {
|
||||
router.push(`/clients/${clientId}/edit`)
|
||||
router.push({ path: `/clients/${clientId}/edit`, state: { tab: activeTab.value } })
|
||||
}
|
||||
|
||||
// ── Archivage / Restauration ────────────────────────────────────────────────
|
||||
|
||||
@@ -3,18 +3,9 @@
|
||||
<PageHeader>
|
||||
{{ t('commercial.clients.title') }}
|
||||
<template #actions>
|
||||
<!-- gap-12 = 48px d'espacement entre Ajouter et Filtres. -->
|
||||
<div class="flex items-center gap-12">
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
variant="secondary"
|
||||
:label="t('commercial.clients.add')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="goToCreate"
|
||||
/>
|
||||
<!-- Bouton Filtres a DROITE d'Ajouter : meme design que
|
||||
l'audit-log. Le compteur reflete les filtres actifs. -->
|
||||
<!-- gap-8 = 32px d'espacement entre Filtres et Ajouter. -->
|
||||
<div class="flex items-center gap-8">
|
||||
<!-- Bouton Filtres a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="tertiary"
|
||||
@@ -22,9 +13,16 @@
|
||||
icon-name="mdi:tune"
|
||||
icon-position="left"
|
||||
icon-size="24"
|
||||
button-class="w-[184px] justify-start gap-4 text-black"
|
||||
@click="openFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
variant="secondary"
|
||||
:label="t('commercial.clients.add')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="goToCreate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
@@ -39,13 +37,13 @@
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
row-clickable
|
||||
table-class="table-fixed"
|
||||
table-class="table-fixed clients-table"
|
||||
:empty-message="t('commercial.clients.empty')"
|
||||
@row-click="onRowClick"
|
||||
@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>
|
||||
@@ -56,7 +54,7 @@
|
||||
<span
|
||||
v-for="site in (item.sites as ClientSite[])"
|
||||
:key="site.id"
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium text-white"
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 font-medium text-white"
|
||||
:style="{ backgroundColor: site.color }"
|
||||
>
|
||||
{{ site.name }}
|
||||
@@ -70,7 +68,7 @@
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="flex justify-center mt-4">
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="primary"
|
||||
@@ -211,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(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -350,7 +348,9 @@ async function loadFilterOptions(): Promise<void> {
|
||||
const [cats, sites] = await Promise.all([
|
||||
api.get<{ member?: Array<{ code: string, name: string }> }>(
|
||||
'/categories',
|
||||
{ pagination: 'false' },
|
||||
// Taxonomie multi-types (ERP-84) : le filtre du repertoire client ne
|
||||
// propose que les categories de type CLIENT (pas les FOURNISSEUR).
|
||||
{ pagination: 'false', typeCode: 'CLIENT' },
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
),
|
||||
api.get<{ member?: Array<{ id: number, name: string }> }>(
|
||||
@@ -419,3 +419,16 @@ onMounted(() => {
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* Colonne Sites uniquement (3e colonne : companyName, categories, SITES,
|
||||
* lastActivity) : ses badges rendent la cellule trop haute. On reduit le padding
|
||||
* vertical de SON td (16px Malio -> 8px) sans toucher les autres colonnes ni les
|
||||
* couleurs/tailles (qui restent sur les defauts Malio).
|
||||
*/
|
||||
:deep(.clients-table tbody td:nth-child(3)) {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user