Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c6103e395 | |||
| b2f17b0522 | |||
| c09501d321 | |||
| 2645a335c4 | |||
| fcc7a2e3d4 | |||
| 269c232bfc | |||
| fa8e46471d | |||
| 277178cf19 | |||
| e0624eace0 | |||
| d3e30e55b2 | |||
| 18e79a643b | |||
| 48f314f09e | |||
| e933c31e0f | |||
| 4c142aecbb | |||
| da35f29960 | |||
| 2a835855b9 | |||
| 585f3c5b79 | |||
| 4a8326a6b5 | |||
| 711774425b | |||
| b1255bb57a | |||
| 25cd6a1ecc | |||
| bb6a4c387b | |||
| 793e816f3e | |||
| 4d2b5ad62f | |||
| a3f2671eec | |||
| 4603ab2832 | |||
| 99c77eb7b6 | |||
| 68f072ef46 | |||
| e2fbf51e19 | |||
| 701a480442 | |||
| 5f5afccac0 | |||
| 617ee314b3 | |||
| 6db955f65c | |||
| 1505e84926 | |||
| a95bb6c629 | |||
| 37eafd276c | |||
| de39fe6a3e |
@@ -6,13 +6,6 @@
|
||||
- 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
|
||||
@@ -20,13 +13,6 @@ Garde-fou : `tests/Architecture/EntityConstraintsHaveFrenchMessageTest` (casse `
|
||||
- Le login `/login_check` est **hors** prefix `/api` (nginx reecrit `REQUEST_URI` vers `/login_check`)
|
||||
- **Exception** : si tu dois creer un controller custom sous `/api/`, mettre `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}`
|
||||
|
||||
## Pagination (obligatoire)
|
||||
|
||||
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(...)`.
|
||||
|
||||
Garde-fou : `tests/Architecture/CollectionsArePaginatedTest` (casse `make test`).
|
||||
→ tableau des cles `pagination_*` + selects + providers ORM/DBAL detailles : skill `backend-entity-conventions`.
|
||||
|
||||
## Repositories
|
||||
|
||||
- Interface : `*RepositoryInterface` dans `Domain/Repository/`
|
||||
@@ -54,20 +40,6 @@ Format obligatoire : `module.resource[.subresource].action` en snake_case.
|
||||
- Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire
|
||||
- Spec complete : @doc/audit-log.md
|
||||
|
||||
### Libelle i18n du type d'entite (obligatoire avec `#[Auditable]`)
|
||||
|
||||
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.
|
||||
|
||||
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/` : `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`.
|
||||
|
||||
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
|
||||
|
||||
Pour embarquer une relation dans le JSON (au lieu d'un IRI Hydra), ajouter le groupe du parent sur les proprietes de l'entite cible.
|
||||
@@ -81,10 +53,3 @@ Exemple : pour qu'`User.profile` soit embarque au lieu d'un lien IRI sous le gro
|
||||
## PostgreSQL
|
||||
|
||||
- Noms de colonnes toujours en **minuscules** dans le SQL brut (commun a tous les projets MALIO)
|
||||
|
||||
## Migrations Doctrine
|
||||
|
||||
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.
|
||||
|
||||
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,44 +44,6 @@ 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`.
|
||||
|
||||
**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` :
|
||||
@@ -91,53 +53,6 @@ Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer
|
||||
|
||||
**Exception** : tableaux purement presentationnels non paginables (diff field/old/new, grille de comparaison, matrice RBAC d'admin, etc.) peuvent rester en `<table>` HTML brut.
|
||||
|
||||
## Listes paginees (standard) — usePaginatedList obligatoire
|
||||
|
||||
**Toute liste qui consomme une `GetCollection` API doit passer par `usePaginatedList`** (`frontend/shared/composables/usePaginatedList.ts`). Le composable est le pendant front de la regle ABSOLUE n°13 (« toute collection est paginee cote back ») : il consomme l'envelope Hydra (`member` / `totalItems` / `view`) et expose un etat reactif a brancher directement sur `MalioDataTable`.
|
||||
|
||||
Pattern de reference :
|
||||
|
||||
```ts
|
||||
const {
|
||||
items,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadList,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
} = usePaginatedList<MyEntity>({ url: '/my-resources' })
|
||||
|
||||
onMounted(loadList)
|
||||
```
|
||||
|
||||
```vue
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
:empty-message="t('foo.empty')"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
/>
|
||||
```
|
||||
|
||||
Garanties offertes par le composable :
|
||||
- Force `Accept: application/ld+json` → API Platform 4 renvoie bien `member` / `totalItems` (sans Accept, retour tableau plat sans pagination).
|
||||
- Defaut 10 items/page, choix client 10 / 25 / 50, aligne sur le defaut serveur.
|
||||
- Mutation `setFilters` / `setSort` / `setItemsPerPage` → retombe systematiquement en page 1.
|
||||
- Cas limite « page hors borne apres filtre » : retombe automatiquement sur la derniere page valide (`tests/usePaginatedList.test.ts`).
|
||||
- Etat 100 % local (refs internes a l'instance) — **jamais reflete dans l'URL**, conformement a la regle « Etat des tableaux — pas de persistance URL » ci-dessous.
|
||||
|
||||
A NE PAS faire :
|
||||
- Charger une collection complete via `?itemsPerPage=999` pour bypasser la pagination. Le seul cas legitime de retour complet est l'alimentation d'un `<select>` sur un referentiel ≤ quelques dizaines d'entrees, et il passe par `?pagination=false` (echappatoire prevue par `pagination_client_enabled: true`).
|
||||
- Reimplementer la pagination prev/next a la main au-dessus de `MalioDataTable` — le composant porte deja le selecteur items/page et les boutons Prev/Next.
|
||||
- Persister `page`/`tri`/`filtre` dans la query string — meme regle que pour `<MalioDataTable>` brut (cf. section suivante).
|
||||
|
||||
## Etat des tableaux — pas de persistance URL
|
||||
|
||||
**Interdit** de persister l'etat d'un tableau (filtres, pagination, tri par colonne, selection, ligne active, scroll) dans la query string ou de le reinjecter depuis `route.query` au montage.
|
||||
@@ -146,18 +61,6 @@ 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
|
||||
|
||||
@@ -41,8 +41,8 @@ Si une verification echoue ou ne peut pas etre lancee (ex : container pas demarr
|
||||
|
||||
## Time tracking Lesstime
|
||||
|
||||
Au demarrage de toute tache de dev sur Starseed, creer une time entry via l'API Lesstime (cf. `~/.claude/CLAUDE.md` pour la procedure complete).
|
||||
- Projet : `/api/projects/6` (STARSEED)
|
||||
Au demarrage de toute tache de dev sur Coltura, creer une time entry via l'API Lesstime (cf. `~/.claude/CLAUDE.md` pour la procedure complete).
|
||||
- Projet : `/api/projects/6` (COLTURA)
|
||||
- Tags : choisir selon le type (Backend `3`, Frontend `2`, Infra `5`, UI/UX `4`, Maintenance `6`, Gestion projet `9`, etc.)
|
||||
|
||||
## Fix `make cache-clear` (permissions `var/`)
|
||||
@@ -50,17 +50,17 @@ Au demarrage de toute tache de dev sur Starseed, creer une time entry via l'API
|
||||
Si `make cache-clear` echoue sur les permissions de `var/` :
|
||||
|
||||
```bash
|
||||
docker exec -t -u root php-starseed-fpm chown -R www-data:www-data /var/www/html/var
|
||||
docker exec -t -u www-data php-starseed-fpm php bin/console cache:clear
|
||||
docker exec -t -u root php-coltura-fpm chown -R www-data:www-data /var/www/html/var
|
||||
docker exec -t -u www-data php-coltura-fpm php bin/console cache:clear
|
||||
```
|
||||
|
||||
A terme : integrer ce fix dans le `makefile` lui-meme.
|
||||
|
||||
## Docker — references utiles
|
||||
|
||||
- Container PHP : `php-starseed-fpm`
|
||||
- Container Nginx : `nginx-starseed` (port 8083)
|
||||
- Container PHP : `php-coltura-fpm`
|
||||
- Container Nginx : `nginx-coltura` (port 8083)
|
||||
- Container DB : PostgreSQL port **5437** (interne et externe)
|
||||
- Config dev : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
||||
- Config prod : `infra/prod/` (Dockerfile multi-stage, `docker-compose.prod.yml`)
|
||||
- Apres modif nginx : `docker restart nginx-starseed`
|
||||
- Apres modif nginx : `docker restart nginx-coltura`
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: create-module
|
||||
description: Scaffold a new Starseed module (backend + frontend) and optionally wire its entries into the sidebar config. Use when the user asks to create, add, scaffold, or generate a new module — e.g., "crée un module Paie", "add a Pointage module", "ajoute un module RH". The backend is the source of truth for activation and sidebar layout; the frontend scans modules automatically.
|
||||
description: Scaffold a new Coltura module (backend + frontend) and optionally wire its entries into the sidebar config. Use when the user asks to create, add, scaffold, or generate a new module — e.g., "crée un module Paie", "add a Pointage module", "ajoute un module RH". The backend is the source of truth for activation and sidebar layout; the frontend scans modules automatically.
|
||||
---
|
||||
|
||||
# Create a new Starseed module
|
||||
# Create a new Coltura module
|
||||
|
||||
Scaffolds a new module across backend and frontend following Starseed's modular monolith DDD architecture.
|
||||
Scaffolds a new module across backend and frontend following Coltura's modular monolith DDD architecture.
|
||||
|
||||
## Architecture reminder — read before acting
|
||||
|
||||
@@ -178,8 +178,8 @@ Execute in this exact order:
|
||||
6. **Backend: sidebar** — if the user wants sidebar entries, edit `config/sidebar.php`.
|
||||
7. **Frontend: translations** — edit `frontend/i18n/locales/fr.json`.
|
||||
8. **Verify** — run:
|
||||
- `docker exec -t -u root php-starseed-fpm chown -R www-data:www-data /var/www/html/var` (avoid permission issues)
|
||||
- `docker exec -t -u www-data php-starseed-fpm php bin/console cache:clear` (validates backend)
|
||||
- `docker exec -t -u root php-coltura-fpm chown -R www-data:www-data /var/www/html/var` (avoid permission issues)
|
||||
- `docker exec -t -u www-data php-coltura-fpm php bin/console cache:clear` (validates backend)
|
||||
- `cd frontend && npx nuxi prepare` (validates Nuxt auto-detection of the new layer)
|
||||
9. **Report** — list files created, the route(s) to test, and the sidebar items added.
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@ jobs:
|
||||
run: |
|
||||
docker build \
|
||||
-f infra/prod/Dockerfile \
|
||||
-t gitea.malio.fr/malio-dev/starseed:${{ gitea.ref_name }} \
|
||||
-t gitea.malio.fr/malio-dev/starseed:latest \
|
||||
-t gitea.malio.fr/malio-dev/coltura:${{ gitea.ref_name }} \
|
||||
-t gitea.malio.fr/malio-dev/coltura:latest \
|
||||
.
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
docker push gitea.malio.fr/malio-dev/starseed:${{ gitea.ref_name }}
|
||||
docker push gitea.malio.fr/malio-dev/starseed:latest
|
||||
docker push gitea.malio.fr/malio-dev/coltura:${{ gitea.ref_name }}
|
||||
docker push gitea.malio.fr/malio-dev/coltura:latest
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
name: Pull Request — Quality gate
|
||||
|
||||
# Lance les tests + lint + build sur chaque PR ciblant develop.
|
||||
# Deux jobs en parallele (backend / frontend) pour reduire le temps de feedback.
|
||||
# E2E volontairement hors scope (cf. regle d'or testing.md).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
# Annule les runs obsoletes quand on repush sur la meme PR.
|
||||
concurrency:
|
||||
group: pr-${{ gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend (PHP CS + PHPUnit)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
# Doivent matcher la DATABASE_URL ci-dessous. Le suffixe `_test`
|
||||
# est applique automatiquement par Doctrine en APP_ENV=test.
|
||||
POSTGRES_USER: app
|
||||
POSTGRES_PASSWORD: '!ChangeMe!'
|
||||
POSTGRES_DB: app
|
||||
# Pas de `ports:` host mapping — le runner partage l'hote avec la
|
||||
# prod (Postgres deja sur 5432) et les jobs Gitea Actions tournent
|
||||
# en container sur un reseau Docker dedie : le service est joignable
|
||||
# via son nom (`postgres`), pas via 127.0.0.1.
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U app"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
APP_ENV: test
|
||||
APP_SECRET: ci-secret-not-used
|
||||
APP_DEBUG: 0
|
||||
DEFAULT_URI: http://localhost/
|
||||
DATABASE_URL: postgresql://app:!ChangeMe!@postgres:5432/app?serverVersion=16&charset=utf8
|
||||
JWT_SECRET_KEY: '%kernel.project_dir%/config/jwt/private.pem'
|
||||
JWT_PUBLIC_KEY: '%kernel.project_dir%/config/jwt/public.pem'
|
||||
JWT_PASSPHRASE: change_me_in_env_local
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP 8.4
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.4'
|
||||
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium
|
||||
coverage: none
|
||||
tools: composer:v2
|
||||
|
||||
# Cache Composer retire : meme cause que cote front — le backend de cache
|
||||
# du runner Gitea est injoignable (ETIMEDOUT) et fait timeouter le step
|
||||
# ~4 min 30. A re-activer si le serveur de cache du runner est repare.
|
||||
- name: Install PHP dependencies
|
||||
run: composer install --no-interaction --no-progress --prefer-dist
|
||||
|
||||
- name: Generate JWT keypair
|
||||
run: php bin/console lexik:jwt:generate-keypair --skip-if-exists --no-interaction
|
||||
|
||||
- name: PHP CS Fixer (dry-run)
|
||||
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
|
||||
|
||||
- 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_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
|
||||
# qui attendent 409 recoivent 201.
|
||||
run: |
|
||||
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
|
||||
php bin/console doctrine:migrations:migrate --env=test --no-interaction
|
||||
php bin/console doctrine:schema:update --env=test --force --no-interaction
|
||||
# Rejoue le catalogue COMMENT ON apres schema:update (cf. ERP-67) :
|
||||
# schema:update drop les commentaires des tables managees par l'ORM.
|
||||
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_active ON category (LOWER(name)) WHERE deleted_at IS NULL"
|
||||
|
||||
- name: Run PHPUnit
|
||||
run: php -d memory_limit=512M vendor/bin/phpunit
|
||||
|
||||
frontend:
|
||||
name: Frontend (lint + Vitest + build)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Pas de `cache: npm` : le backend de cache du runner Gitea est injoignable
|
||||
# (ETIMEDOUT) et chaque tentative de restauration attend ~4 min 30 avant de
|
||||
# timeout — c'est ce qui plombait le job. Node 22 est deja dans le
|
||||
# tool-cache du runner (install instantane), et `npm ci` a froid ne prend
|
||||
# que ~30s. A re-activer si le serveur de cache du runner est repare.
|
||||
- name: Setup Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install Node dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: ESLint
|
||||
run: npm run lint
|
||||
|
||||
- name: Unit tests (Vitest)
|
||||
run: npm run test
|
||||
|
||||
# `nuxt build` (et non `build:dist`/`nuxt generate`) : l'app est en SSR off
|
||||
# (SPA), le prerender de generate n'apporte rien a une quality gate — on
|
||||
# veut seulement valider que le bundle compile.
|
||||
- name: Build production (nuxt build)
|
||||
run: npm run build
|
||||
@@ -3,7 +3,6 @@
|
||||
/.env.local.php
|
||||
/.env.*.local
|
||||
/config/secrets/dev/dev.decrypt.private.php
|
||||
/config/reference.php
|
||||
/public/bundles/
|
||||
/var/
|
||||
/vendor/
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
Liste des évolutions du projet Starseed
|
||||
Liste des évolutions du projet Coltura
|
||||
|
||||
## [0.0.0]
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# Starseed
|
||||
# Coltura
|
||||
|
||||
## Contexte
|
||||
CRM/ERP en architecture **modular monolith DDD**. Le backend est la source de verite unique (modules actifs, sidebar). Le frontend scanne `frontend/modules/*/` comme layers Nuxt et consomme l'API pour la navigation. Multi-tenant : chaque module est activable/desactivable.
|
||||
|
||||
Doc humaine : `README.md` — Spec audit : `doc/audit-log.md` (à lire à la demande, non chargés en permanence).
|
||||
Doc humaine : @README.md — Spec audit : @doc/audit-log.md
|
||||
|
||||
## Stack
|
||||
- Backend : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16 (port 5437)
|
||||
- Frontend : Nuxt 4 (SPA), Vue 3, Pinia, Tailwind, @malio/layer-ui, @nuxtjs/i18n
|
||||
- Auth : JWT HTTP-only cookie (Lexik), login a `/login_check`
|
||||
- Containers : `php-starseed-fpm`, `nginx-starseed` (port 8083), dev Nuxt port **3004**
|
||||
- Containers : `php-coltura-fpm`, `nginx-coltura` (port 8083), dev Nuxt port **3004**
|
||||
|
||||
## Regles ABSOLUES
|
||||
|
||||
@@ -24,9 +24,6 @@ Doc humaine : `README.md` — Spec audit : `doc/audit-log.md` (à lire à la dem
|
||||
9. **Jamais commit sans demande explicite** de l'utilisateur ; jamais force push sans confirmation.
|
||||
10. **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Pas de `Co-Authored-By: Claude`, pas de `Generated with Claude Code`, pas de `🤖`, pas d'emoji robot, rien. Les commits sont signes par l'utilisateur uniquement.
|
||||
11. **Migrations d'initialisation au namespace racine** `DoctrineMigrations` dans `migrations/` (setup user, RBAC, seed de base). Les migrations modulaires (`src/Module/*/Infrastructure/Doctrine/Migrations/`) sont reservees aux evolutions post-schema (ajout de colonnes, index) — cf. @.claude/rules/architecture.md pour la raison.
|
||||
12. **Toujours documenter chaque colonne BDD via `COMMENT ON COLUMN`** dans la migration qui la cree ou la modifie. Description en francais, courte (≤ 200 caracteres), explique la semantique metier + contraintes implicites (unicite partielle, FK importante, lien RG). Garde-fou : `tests/Architecture/ColumnsHaveSqlCommentTest` echoue si une colonne `public` n'a pas de description (`col_description IS NULL`). Details et exemples : @.claude/rules/backend.md § Migrations Doctrine.
|
||||
13. **Toujours paginer toute collection exposee par l'API.** Aucun retour de collection complete (pas de provider qui retourne un array brut). Standard pose dans `config/packages/api_platform.yaml` : 10 items par defaut, max 50, le client peut basculer entre 10/25/50 et peut envoyer `?pagination=false` pour alimenter un select. Garde-fou : `tests/Architecture/CollectionsArePaginatedTest` echoue si une `GetCollection` desactive la pagination sans whitelist. Details et exemples : @.claude/rules/backend.md § Pagination.
|
||||
14. **`symfony.lock` est versionne** (au meme titre que `composer.lock`) — ne JAMAIS le `.gitignore`. C'est le registre des recipes Flex appliquees : sans lui, chaque `composer require` rejoue toutes les recipes et repollue `.env`, `config/bundles.php`, `docker-compose.yml` et recree du scaffolding parasite (`src/Entity/`, `src/Controller/`...). Le regenerer si besoin via `composer recipes:install --force`.
|
||||
|
||||
## Conventions
|
||||
@.claude/rules/architecture.md
|
||||
@@ -37,7 +34,7 @@ Doc humaine : `README.md` — Spec audit : `doc/audit-log.md` (à lire à la dem
|
||||
@.claude/rules/git.md
|
||||
@.claude/rules/workflow.md
|
||||
|
||||
## Commandes (liste complete dans `README.md`)
|
||||
## Commandes (liste complete dans @README.md)
|
||||
|
||||
- Demarrer : `make start`
|
||||
- Dev front (hot reload) : `make dev-nuxt` (port 3004)
|
||||
@@ -55,7 +52,6 @@ Editer uniquement `config/modules.php` (commenter la ligne). Cascade automatique
|
||||
## A NE PAS faire
|
||||
|
||||
- Pas de controller Symfony custom sous `/api/` sans `priority: 1` sur `#[Route]` (conflit API Platform `{id}`).
|
||||
- Pas de provider API Platform qui retourne un array brut sur une `GetCollection` — court-circuite la pagination Hydra (`totalItems` / `view` absents). Utiliser `ApiPlatform\Doctrine\Orm\Paginator` (ORM) ou un paginator implementant `PaginatorInterface` (DBAL — cf. `DbalPaginator`).
|
||||
- Pas de `getClientMimeType()` pour valider un upload — utiliser `$file->getMimeType()` (serveur).
|
||||
- Pas de hardcode de la sidebar cote front, pas de `modules-loader.ts` ni `.module.ts`.
|
||||
- Pas d'edition manuelle de `extends` dans `frontend/nuxt.config.ts` — les layers sont scannes automatiquement.
|
||||
@@ -70,5 +66,3 @@ Editer uniquement `config/modules.php` (commenter la ligne). Cascade automatique
|
||||
## Credentials (dev)
|
||||
|
||||
`admin` / `admin` (ROLE_ADMIN) ; `alice` / `alice`, `bob` / `bob` (ROLE_USER).
|
||||
|
||||
Comptes demo des roles metier (seedes par `RbacDemoFixtures` / `app:seed-rbac --with-demo-users`, mot de passe `demo`) : `bureau` / `demo`, `compta` / `demo`, `commerciale` / `demo`, `usine` / `demo`. Matrice RBAC § 2.7 (M1 Clients) attachee aux roles correspondants.
|
||||
|
||||
@@ -1,362 +1,181 @@
|
||||
# Starseed
|
||||
# Coltura
|
||||
|
||||
CRM/ERP en architecture **modular monolith DDD** — Symfony 8 (API Platform 4) + Nuxt 4.
|
||||
|
||||
Le backend est la **source de vérité unique** : il décide des modules actifs et de
|
||||
l'organisation de la sidebar. Le frontend scanne `frontend/modules/*/` comme layers
|
||||
Nuxt et consomme l'API pour la navigation.
|
||||
|
||||
---
|
||||
|
||||
## Sommaire
|
||||
|
||||
- [Stack](#stack)
|
||||
- [Prérequis](#prérequis)
|
||||
- [Démarrage rapide](#démarrage-rapide)
|
||||
- [Dev local : avec ou sans données de seed](#dev-local--avec-ou-sans-données-de-seed)
|
||||
- [Comptes (dev)](#comptes-dev)
|
||||
- [Bases de données : dev et test](#bases-de-données--dev-et-test)
|
||||
- [Tests](#tests)
|
||||
- [Déploiement : seed RBAC en recette / prod](#déploiement--seed-rbac-en-recette--prod)
|
||||
- [Commandes make](#commandes-make)
|
||||
- [Architecture](#architecture)
|
||||
- [Structure du dépôt](#structure-du-dépôt)
|
||||
- [CI/CD](#cicd)
|
||||
- [Conventions](#conventions)
|
||||
|
||||
---
|
||||
CRM/ERP — Symfony 8 (API Platform 4) + Nuxt 4
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend** : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16
|
||||
- **Frontend** : Nuxt 4 (SPA, SSR off), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, @nuxtjs/i18n
|
||||
- **Auth** : JWT HTTP-only cookie (Lexik), login sur `/login_check`
|
||||
- **Frontend** : Nuxt 4 (SPA), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui
|
||||
- **Auth** : JWT HTTP-only cookie (Lexik)
|
||||
- **Infra** : Docker Compose (dev + prod multi-stage)
|
||||
- **CI/CD** : Gitea Actions (auto-tag + build Docker)
|
||||
|
||||
| Service | Port |
|
||||
|---------------|------|
|
||||
| API (Nginx) | 8083 |
|
||||
| Frontend dev | 3004 |
|
||||
| PostgreSQL | 5437 |
|
||||
|
||||
---
|
||||
|
||||
## Prérequis
|
||||
|
||||
- Docker + Docker Compose
|
||||
- `make`
|
||||
- `nvm` (la version de Node est fixée par `.nvmrc`, voir `make node-use`)
|
||||
|
||||
Toutes les commandes `make` s'exécutent dans le container PHP (`php-starseed-fpm`) ;
|
||||
rien n'est requis sur l'hôte hormis Docker — **sauf les tests E2E**, qui tournent sur
|
||||
l'hôte (navigateur réel, voir [Tests](#tests)).
|
||||
|
||||
---
|
||||
|
||||
## Démarrage rapide
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
make start # Démarre les containers Docker
|
||||
make install # Composer + clés JWT + migrations + permissions + BDD de test
|
||||
make dev-nuxt # Serveur Nuxt avec hot reload (http://localhost:3004)
|
||||
make start # Demarrer les containers Docker
|
||||
make install # Composer, migrations, fixtures, build Nuxt
|
||||
```
|
||||
|
||||
`make install` prépare une base de dev **vierge** (schéma + RBAC structurel, sans
|
||||
données de démo) et la base de **test**. Pour obtenir des comptes et des données de
|
||||
démo prêtes à l'emploi, lis la section suivante.
|
||||
|
||||
> Override local possible : `make` lit `infra/dev/.env.docker`, surchargé par
|
||||
> `infra/dev/.env.docker.local` s'il existe (créé automatiquement par `make env-init`).
|
||||
|
||||
---
|
||||
|
||||
## Dev local : avec ou sans données de seed
|
||||
|
||||
Le projet distingue deux états de base de données de dev. Les **fixtures Doctrine sont
|
||||
en `require-dev`** : elles n'existent qu'en dev, jamais dans le build de prod.
|
||||
|
||||
### Sans données de seed (base vierge)
|
||||
|
||||
C'est ce que produit `make install`. La base contient :
|
||||
|
||||
- le **schéma** complet (toutes les migrations jouées) ;
|
||||
- les **rôles système** `admin` / `user` (seedés en SQL par la migration RBAC) ;
|
||||
- le **catalogue de permissions** synchronisé (`app:sync-permissions`).
|
||||
|
||||
Mais **aucun compte utilisateur ni donnée métier**. Pour pouvoir te connecter,
|
||||
crée toi-même un compte :
|
||||
Dev frontend (hot reload) :
|
||||
|
||||
```bash
|
||||
make shell
|
||||
php bin/console app:create-user admin monMotDePasse --admin # compte ROLE_ADMIN
|
||||
make dev-nuxt # Port 3003
|
||||
```
|
||||
|
||||
Optionnel — provisionner les **rôles métier** (bureau / compta / commerciale / usine
|
||||
+ matrice RBAC § 2.7) sans comptes de démo :
|
||||
## Ports
|
||||
|
||||
```bash
|
||||
php bin/console app:seed-rbac
|
||||
```
|
||||
| Service | Port |
|
||||
|------------|------|
|
||||
| API (Nginx)| 8083 |
|
||||
| Frontend | 3004 |
|
||||
| PostgreSQL | 5437 |
|
||||
|
||||
Cet état est utile pour repartir d'une base propre, reproduire un bug sur données
|
||||
minimales, ou tester un parcours d'onboarding réel.
|
||||
## Commandes
|
||||
|
||||
### Avec données de seed (base de démo)
|
||||
|
||||
`make db-reset` (ou `make fixtures` après un `make install`) recharge la base de dev
|
||||
avec un jeu complet de données de démonstration, **idempotent** :
|
||||
|
||||
```bash
|
||||
make db-reset # ATTENTION : drop + recrée la base de dev, puis charge tout le seed
|
||||
```
|
||||
|
||||
Ce que les fixtures posent :
|
||||
|
||||
- **3 utilisateurs système** : `admin` (ROLE_ADMIN), `alice`, `bob` (ROLE_USER),
|
||||
rattachés à des sites distincts ;
|
||||
- **3 sites** : Chatellerault, Saint-Jean, Pommevic ;
|
||||
- **les comptes de démo RBAC métier** (`bureau`, `compta`, `commerciale`, `usine`,
|
||||
mot de passe `demo`) avec la matrice § 2.7 attachée ;
|
||||
- les **référentiels et données métier** des modules (catégories, clients de démo,
|
||||
référentiels comptables…).
|
||||
|
||||
Toutes les fixtures sont rejouables sans effet de bord (lookup par clé naturelle,
|
||||
aucun doublon).
|
||||
|
||||
> Différence avec `make install` : `install` ne charge **pas** les fixtures sur la base
|
||||
> de dev (il alimente uniquement la base de test). Utilise `make db-reset` ou
|
||||
> `make fixtures` quand tu veux des données de démo en dev.
|
||||
|
||||
---
|
||||
|
||||
## Comptes (dev)
|
||||
|
||||
Disponibles uniquement après `make db-reset` / `make fixtures` (état « avec seed ») :
|
||||
|
||||
| Username | Password | Rôle | RBAC métier |
|
||||
|---------------|----------|------------|---------------------------------------------------------------|
|
||||
| `admin` | `admin` | ROLE_ADMIN | bypass complet (`is_admin`) |
|
||||
| `alice` | `alice` | ROLE_USER | — |
|
||||
| `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 |
|
||||
| `usine` | `demo` | ROLE_USER | aucun accès clients |
|
||||
|
||||
---
|
||||
|
||||
## Bases de données : dev et test
|
||||
|
||||
Deux bases distinctes vivent dans le **même container PostgreSQL** (port 5437) :
|
||||
|
||||
| Base | Environnement | Construite par | Usage |
|
||||
|------------|---------------|--------------------------------------|--------------------------------|
|
||||
| `<db>` | `dev` | `make install` / `make db-reset` | développement manuel, dev-nuxt |
|
||||
| `<db>_test` | `test` | `make test-db-setup` | PHPUnit (jamais touchée à la main) |
|
||||
|
||||
Le suffixe `_test` est appliqué **automatiquement** par Doctrine quand `APP_ENV=test`
|
||||
(config `when@test` dans `config/packages/doctrine.yaml`). La base de test est donc
|
||||
totalement **isolée** de la base de dev : lancer `make test` ne touche jamais tes
|
||||
données de dev.
|
||||
|
||||
`make test-db-setup` fait davantage que jouer les migrations, car certaines structures
|
||||
ne sont pas portées par des migrations « métier » :
|
||||
|
||||
1. `doctrine:migrations:migrate` — schéma métier réel ;
|
||||
2. `doctrine:schema:update --force` — crée les tables mappées en `when@test`
|
||||
uniquement (entités de test) ;
|
||||
3. `app:apply-column-comments` — réapplique les `COMMENT ON COLUMN` que
|
||||
`schema:update` efface sur les tables managées par l'ORM (garde-fou
|
||||
`ColumnsHaveSqlCommentTest`) ;
|
||||
4. `fixtures:load` → `sync-permissions` → `seed-rbac` — dans cet ordre précis
|
||||
(le purger des fixtures vide la table `permission`, donc la sync passe après) ;
|
||||
5. recréation des **index partiels uniques** (`LOWER(...) WHERE ...`) non exprimables
|
||||
en attributs ORM, indispensables aux tests d'unicité (RG-1.07, RG-1.16, RG-1.03/1.29).
|
||||
|
||||
`make install` et `make db-reset` appellent déjà `test-db-setup` : tu n'as à le
|
||||
relancer à la main que si la base de test diverge (nouvelle migration, nouvelle
|
||||
permission) sans vouloir reseed la base de dev.
|
||||
|
||||
---
|
||||
| Commande | Description |
|
||||
|----------|-------------|
|
||||
| `make start` | Demarrer les containers |
|
||||
| `make stop` | Arreter les containers |
|
||||
| `make restart` | Redemarrer les containers |
|
||||
| `make install` | Install complet |
|
||||
| `make reset` | Tout supprimer et reinstaller |
|
||||
| `make dev-nuxt` | Serveur dev Nuxt (hot reload) |
|
||||
| `make shell` | Shell dans le container PHP |
|
||||
| `make cache-clear` | Vider le cache Symfony |
|
||||
| `make migration-migrate` | Lancer les migrations |
|
||||
| `make fixtures` | Charger les fixtures |
|
||||
| `make db-reset` | Reset BDD + migrations + fixtures |
|
||||
| `make test` | PHPUnit (tests back) |
|
||||
| `make nuxt-test` | Vitest (tests unitaires front) |
|
||||
| `make test-e2e` | Playwright (tests E2E front) |
|
||||
| `make test-e2e-ui` | Playwright UI interactive (debug) |
|
||||
| `make seed-e2e` | Seed les 6 personas E2E |
|
||||
| `make install-e2e-deps` | One-time : Chromium + libs systeme (sudo) |
|
||||
| `make php-cs-fixer-allow-risky` | Fix code style PHP |
|
||||
| `make logs-dev` | Tail logs Symfony |
|
||||
|
||||
## Tests
|
||||
|
||||
| Suite | Commande | Outil | Où |
|
||||
|-------------------|------------------|----------------------|-----------------------------------|
|
||||
| Back | `make test` | PHPUnit | container PHP, base `<db>_test` |
|
||||
| Front unitaire | `make nuxt-test` | Vitest (happy-dom) | container Node, < 30 s |
|
||||
| Front E2E | `make test-e2e` | Playwright | **hôte** (navigateur réel requis) |
|
||||
| Tout (back+front) | `make test-all` | PHPUnit + Vitest | — |
|
||||
|
||||
### Tests back (PHPUnit)
|
||||
- **Back** : `make test` (PHPUnit). Fixtures dediees sous `tests/Fixtures/`.
|
||||
- **Front unitaire** : `make nuxt-test` (Vitest, happy-dom). Composables, utils, stores — rapide, <30s.
|
||||
- **Front E2E** : `make test-e2e` (Playwright). Couvre login + matrice RBAC sidebar. Suite volontairement minimaliste (11 tests) — voir la regle d'or dans `CLAUDE.md`.
|
||||
|
||||
**Bootstrap E2E (une fois par poste)** :
|
||||
```bash
|
||||
make test # toute la suite
|
||||
make test FILES=tests/Module/Commercial # un dossier / fichier ciblé
|
||||
make install-e2e-deps # Telecharge Chromium + libs systeme via apt (sudo)
|
||||
```
|
||||
|
||||
PHPUnit force `APP_ENV=test` (`phpunit.dist.xml`) : les tests tournent **toujours**
|
||||
sur la base `<db>_test`, jamais sur la base de dev. Prérequis : que la base de test
|
||||
existe — c'est le cas après `make install`. Si elle a divergé, rejoue
|
||||
`make test-db-setup` (cf. [Bases de données](#bases-de-données--dev-et-test)).
|
||||
|
||||
### Tests front unitaires (Vitest)
|
||||
|
||||
**Workflow E2E** :
|
||||
```bash
|
||||
make nuxt-test # composables, utils, stores — rapide et stable
|
||||
```
|
||||
|
||||
C'est la **place par défaut** pour étendre la couverture (cf. règle d'or ci-dessous).
|
||||
|
||||
### Tests E2E (Playwright)
|
||||
|
||||
Suite volontairement minimaliste (login + matrice RBAC sidebar). **Règle d'or : un
|
||||
nouveau test E2E ne s'ajoute que si un bug critique est passé en prod** — sinon,
|
||||
préférer un test Vitest ou étendre un persona existant.
|
||||
|
||||
Bootstrap (une fois par poste) :
|
||||
|
||||
```bash
|
||||
make install-e2e-deps # télécharge Chromium + libs système (apt/dnf, sudo)
|
||||
```
|
||||
|
||||
Workflow :
|
||||
|
||||
```bash
|
||||
# Terminal 1 — containers, seed des personas, serveur dev
|
||||
# Terminal 1 : containers + dev server
|
||||
make start && make seed-e2e && make dev-nuxt
|
||||
|
||||
# Terminal 2 — tests
|
||||
make test-e2e # headless
|
||||
make test-e2e-ui # UI interactive (debug)
|
||||
# Terminal 2 : tests
|
||||
make test-e2e
|
||||
```
|
||||
|
||||
> Toute permission testable touche **3 miroirs** à garder alignés : `config/sidebar.php`,
|
||||
> `frontend/tests/e2e/_fixtures/personas.ts`, `SeedE2ECommand.php`.
|
||||
|
||||
---
|
||||
|
||||
## Déploiement : seed RBAC en recette / prod
|
||||
|
||||
Les fixtures Doctrine étant en `require-dev`, elles sont **absentes du build de prod**.
|
||||
Le RBAC métier (rôles `bureau` / `compta` / `commerciale` / `usine` + matrice § 2.7)
|
||||
est seedé par une **commande applicative idempotente**, jouée dans l'étape de release,
|
||||
**après** les migrations et la synchronisation des permissions :
|
||||
|
||||
```bash
|
||||
php bin/console doctrine:migrations:migrate --no-interaction
|
||||
php bin/console app:sync-permissions # pose les permissions (commercial.clients.*, …)
|
||||
php bin/console app:seed-rbac # PROD : rôles + matrice § 2.7 (sans comptes démo)
|
||||
```
|
||||
|
||||
En **recette / staging**, ajouter le flag pour disposer de logins de test. Le mot de
|
||||
passe est fourni **explicitement** (jamais en dur, jamais committé) :
|
||||
|
||||
```bash
|
||||
php bin/console app:seed-rbac --with-demo-users --password='<mot-de-passe>'
|
||||
# ou via la variable d'environnement RBAC_DEMO_PASSWORD
|
||||
```
|
||||
|
||||
La commande est rejouable sans effet de bord (aucun doublon de rôle, de lien ou de
|
||||
compte). Pour créer un premier administrateur en prod :
|
||||
|
||||
```bash
|
||||
php bin/console app:create-user <username> <password> --admin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commandes make
|
||||
|
||||
`make` (sans argument) ou `make help` affiche l'aide colorée. Les principales :
|
||||
|
||||
| Commande | Description |
|
||||
|--------------------------------|----------------------------------------------------------|
|
||||
| `make start` / `stop` / `restart` | Cycle de vie des containers |
|
||||
| `make install` | Install complet (base dev vierge + base de test) |
|
||||
| `make reset` | Tout supprimer et réinstaller (**drop la BDD**) |
|
||||
| `make dev-nuxt` | Serveur Nuxt hot reload (port 3004) |
|
||||
| `make shell` / `shell-root` | Shell bash dans le container PHP |
|
||||
| `make migration-migrate` | Jouer les migrations Doctrine |
|
||||
| `make fixtures` | Charger les fixtures (données de démo dev) |
|
||||
| `make sync-permissions` | Synchroniser le catalogue RBAC |
|
||||
| `make seed-rbac` | Seed RBAC métier (rôles + matrice § 2.7) |
|
||||
| `make db-reset` | Reset base dev : drop + migrate + fixtures + RBAC |
|
||||
| `make test-db-setup` | (Re)construire la base de test |
|
||||
| `make test` | PHPUnit (back) |
|
||||
| `make nuxt-test` | Vitest (front unitaire) |
|
||||
| `make test-all` | PHPUnit + Vitest |
|
||||
| `make test-e2e` / `test-e2e-ui`| Playwright (E2E, sur l'hôte) |
|
||||
| `make seed-e2e` | Seed des 6 personas E2E |
|
||||
| `make php-cs-fixer-allow-risky`| Fix du code style PHP |
|
||||
| `make php-cs-fixer-check` | Dry-run du fixer (CI / avant push) |
|
||||
| `make logs-dev` | Tail des logs Symfony |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
**Modular Monolith DDD** : chaque module est un bounded context autonome,
|
||||
activable / désactivable par tenant. Le backend est la seule source de vérité pour
|
||||
l'activation des modules et l'organisation de la sidebar.
|
||||
**Modular Monolith DDD** : chaque module est un bounded context autonome, activable/desactivable par tenant. Le backend est la seule source de verite pour l'activation et l'organisation de la sidebar.
|
||||
|
||||
- `config/modules.php` — liste des modules actifs
|
||||
- `config/sidebar.php` — structure de la sidebar (sections + items avec module owner)
|
||||
- `GET /api/modules` — IDs des modules actifs (public)
|
||||
- `GET /api/sidebar` — sections filtrées par modules actifs + routes désactivées (public)
|
||||
- `GET /api/sidebar` — retourne les sections filtrees par les modules actifs + les routes desactivees
|
||||
- Frontend : chaque `frontend/modules/*/` est auto-detecte comme layer Nuxt, la sidebar est fetchee de l'API
|
||||
|
||||
**Désactiver un module** : commenter sa ligne dans `config/modules.php`, vider le cache.
|
||||
Ses items de sidebar disparaissent et ses routes sont bloquées par le middleware front.
|
||||
Le code reste dans le bundle (layer auto-détecté) → réactivation instantanée.
|
||||
Pour desactiver un module : commenter sa ligne dans `config/modules.php`, clear cache. Ses items de sidebar disparaissent et ses routes sont bloquees par le middleware front.
|
||||
|
||||
**Réorganiser la sidebar** : éditer `config/sidebar.php` uniquement — le code des
|
||||
modules n'est pas touché.
|
||||
Pour reorganiser la sidebar (ex: deplacer un item d'une section a l'autre) : editer `config/sidebar.php` uniquement, le code des modules n'est pas touche.
|
||||
|
||||
**Communication inter-modules** : jamais d'import direct d'un module à l'autre. Passer
|
||||
par `Shared/Domain/Contract/` (interfaces) ou des domain events.
|
||||
|
||||
---
|
||||
|
||||
## Structure du dépôt
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/ # Backend Symfony
|
||||
Shared/ # Noyau technique partagé (Domain/, Application/Bus/, Infrastructure/ApiPlatform/)
|
||||
Kernel.php
|
||||
Shared/ # Noyau technique partage
|
||||
Domain/
|
||||
ValueObject/ # Email, ...
|
||||
Event/ # DomainEventInterface
|
||||
Contract/ # Interfaces inter-modules
|
||||
Application/
|
||||
Bus/ # CommandBusInterface, QueryBusInterface
|
||||
Infrastructure/
|
||||
ApiPlatform/
|
||||
Resource/ # AppVersion, ModulesResource, SidebarResource
|
||||
State/ # AppVersionProvider, ModulesProvider, SidebarProvider
|
||||
Module/
|
||||
Core/ # Module obligatoire (auth, users, RBAC)
|
||||
CoreModule.php # Déclaration (ID, LABEL, REQUIRED, permissions())
|
||||
Domain/ Application/ Infrastructure/
|
||||
Commercial/ Catalog/ Sites/ # Modules métier
|
||||
Core/ # Module obligatoire (auth, users)
|
||||
CoreModule.php # Declaration (ID, LABEL, REQUIRED)
|
||||
Domain/
|
||||
Entity/ # User
|
||||
Repository/ # UserRepositoryInterface
|
||||
Event/ # UserCreated
|
||||
Application/
|
||||
DTO/ # UserOutput
|
||||
Infrastructure/
|
||||
Doctrine/ # DoctrineUserRepository, Migrations/
|
||||
ApiPlatform/State/
|
||||
Provider/ # MeProvider
|
||||
Processor/ # UserPasswordHasherProcessor
|
||||
Console/ # CreateUserCommand
|
||||
DataFixtures/ # AppFixtures
|
||||
Commercial/ # Autre module (exemple)
|
||||
CommercialModule.php
|
||||
config/
|
||||
modules.php # Source de vérité : activation
|
||||
sidebar.php # Source de vérité : navigation
|
||||
packages/ # Config Symfony (doctrine, api_platform, security…)
|
||||
migrations/ # Migrations d'initialisation (namespace racine : setup, RBAC, seed de base)
|
||||
modules.php # Source de verite activation
|
||||
sidebar.php # Source de verite navigation
|
||||
version.yaml
|
||||
packages/ # Config Symfony
|
||||
jwt/ # Cles JWT
|
||||
migrations/ # Anciennes migrations
|
||||
frontend/ # App Nuxt 4 (SPA)
|
||||
app/ # Shell : layouts, middlewares (auth.global, modules.global)
|
||||
shared/ # Code inter-modules (composables, stores, utils, types)
|
||||
modules/ # Layers Nuxt auto-détectés (core/, commercial/…)
|
||||
i18n/locales/ # Traductions (sidebar.*, audit.entity.*, …)
|
||||
app/
|
||||
layouts/ # default.vue, auth.vue
|
||||
middleware/ # auth.global.ts, modules.global.ts
|
||||
shared/ # Code partage (hors modules)
|
||||
composables/ # useApi, useAppVersion, useSidebar
|
||||
components/ui/ # AppTopNav, ...
|
||||
stores/ # auth, ui
|
||||
services/ # auth
|
||||
types/ # SidebarSection, UserData
|
||||
utils/ # api (Hydra)
|
||||
modules/ # Modules auto-detectes comme layers Nuxt
|
||||
core/
|
||||
nuxt.config.ts # Marqueur layer
|
||||
pages/ # index, login, logout
|
||||
commercial/
|
||||
nuxt.config.ts
|
||||
pages/ # commercial.vue
|
||||
app.vue
|
||||
nuxt.config.ts # Scanne modules/*/ automatiquement
|
||||
i18n/locales/ # Traductions (sidebar.*, etc.)
|
||||
assets/ # CSS, images
|
||||
public/ # Fichiers statiques
|
||||
infra/
|
||||
dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug, .env.docker)
|
||||
dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug)
|
||||
prod/ # Docker prod (multi-stage, nginx, php-prod.ini)
|
||||
.gitea/workflows/ # CI Gitea (auto-tag, build Docker)
|
||||
.claude/
|
||||
skills/create-module/ # Skill Claude Code pour scaffolder un module
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD
|
||||
|
||||
- **Auto Tag** : push sur `develop` → bump `config/version.yaml` → tag `vX.Y.Z`
|
||||
- **Build Docker** : push tag `v*` → build image multi-stage → push Gitea Registry
|
||||
|
||||
Secrets requis dans Gitea :
|
||||
|
||||
- `RELEASE_TOKEN` — PAT avec droits `write:repository`
|
||||
- `REGISTRY_TOKEN` — token pour le registry Docker
|
||||
|
||||
---
|
||||
## Credentials (dev)
|
||||
|
||||
| Username | Password | Role |
|
||||
|----------|----------|------|
|
||||
| admin | admin | ROLE_ADMIN |
|
||||
| alice | alice | ROLE_USER |
|
||||
| bob | bob | ROLE_USER |
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -366,13 +185,4 @@ Secrets requis dans Gitea :
|
||||
<type>(<scope optionnel>) : <message>
|
||||
```
|
||||
|
||||
Espaces obligatoires autour du `:`. Types : `build`, `chore`, `ci`, `docs`, `feat`,
|
||||
`fix`, `perf`, `refactor`, `revert`, `style`, `test`.
|
||||
|
||||
### Langue
|
||||
|
||||
- UI et communication : **français**
|
||||
- Code (classes, méthodes, variables) : **anglais**
|
||||
- Commentaires (PHP, TS, Vue) : **français**
|
||||
|
||||
> Règles détaillées : `CLAUDE.md` et `.claude/rules/`.
|
||||
Types : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`
|
||||
|
||||
@@ -39,7 +39,7 @@ La branche est globalement solide : les trois miroirs RBAC sont synchronises, le
|
||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||
```
|
||||
|
||||
La documentation Swagger/OpenAPI d'API Platform est accessible sans authentification, quel que soit l'environnement — y compris en production sur `starseed.malio-dev.fr`. Elle expose :
|
||||
La documentation Swagger/OpenAPI d'API Platform est accessible sans authentification, quel que soit l'environnement — y compris en production sur `coltura.malio-dev.fr`. Elle expose :
|
||||
|
||||
- la liste complete des endpoints (`/api/audit-logs`, `/api/users/{id}/rbac`, `/api/sites`, etc.)
|
||||
- les schemas de securite (`is_granted('core.audit_log.view')`)
|
||||
@@ -94,7 +94,7 @@ Le reverse proxy ecoute uniquement sur le port 80 (HTTP), sans redirection 301 v
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name starseed.malio-dev.fr;
|
||||
server_name coltura.malio-dev.fr;
|
||||
|
||||
# Redirection HTTPS obligatoire (ajouter un server block HTTPS par ailleurs).
|
||||
# Tant que le TLS n'est pas en place, au minimum poser les en-tetes suivants.
|
||||
@@ -123,7 +123,7 @@ User-Agent: *
|
||||
Disallow:
|
||||
```
|
||||
|
||||
La valeur `Disallow:` (vide) signifie "rien n'est interdit" — tous les crawlers peuvent indexer la totalite du site. Pour un outil CRM interne accessible sur un DNS public (`starseed.malio-dev.fr`), c'est un leak inutile : la page de login, les URLs `/admin/*`, les URLs des fiches clients peuvent remonter dans Google.
|
||||
La valeur `Disallow:` (vide) signifie "rien n'est interdit" — tous les crawlers peuvent indexer la totalite du site. Pour un outil CRM interne accessible sur un DNS public (`coltura.malio-dev.fr`), c'est un leak inutile : la page de login, les URLs `/admin/*`, les URLs des fiches clients peuvent remonter dans Google.
|
||||
|
||||
**Correction** :
|
||||
|
||||
@@ -581,7 +581,7 @@ Et ajouter les cles manquantes dans `fr.json` :
|
||||
await loadSidebar() // apres chaque switch
|
||||
```
|
||||
|
||||
Commentaire : *"les filtres de modules peuvent dependre du site courant"*. En pratique, dans `config/sidebar.php` de Starseed aucun item ne depend du site. C'est un aller-retour reseau inutile a chaque switch, et la sidebar peut "flicker" pour l'utilisateur.
|
||||
Commentaire : *"les filtres de modules peuvent dependre du site courant"*. En pratique, dans `config/sidebar.php` de Coltura aucun item ne depend du site. C'est un aller-retour reseau inutile a chaque switch, et la sidebar peut "flicker" pour l'utilisateur.
|
||||
|
||||
**Correction** : rendre le rechargement opt-in ou documenter la raison actuelle (prevoir le futur).
|
||||
|
||||
|
||||
+9
-9
@@ -39,7 +39,7 @@ access_control:
|
||||
Comme `/api/docs` tombe desormais dans le dernier pattern (`^/api`), il faudra etre authentifie pour le voir. Les devs continueront de l'utiliser apres login — les attaquants non.
|
||||
|
||||
3. Recharger : `make cache-clear` puis `make restart`.
|
||||
4. Tester : `curl -i https://starseed.malio-dev.fr/api/docs` doit retourner `401 Unauthorized` (avant : `200`).
|
||||
4. Tester : `curl -i https://coltura.malio-dev.fr/api/docs` doit retourner `401 Unauthorized` (avant : `200`).
|
||||
|
||||
**Fichiers :** `config/packages/security.yaml`
|
||||
|
||||
@@ -47,12 +47,12 @@ Comme `/api/docs` tombe desormais dans le dernier pattern (`^/api`), il faudra e
|
||||
|
||||
### T-002 — Ajouter les en-tetes de securite HTTP de base en prod
|
||||
|
||||
**Pourquoi :** sans `X-Frame-Options`, quelqu'un peut integrer Starseed dans une iframe sur un site tiers et faire du clickjacking (faire croire a l'utilisateur qu'il clique sur un bouton anodin alors qu'il valide une action dans Starseed). Sans `X-Content-Type-Options: nosniff`, un navigateur peut deviner le type MIME et executer un fichier qui n'aurait pas du l'etre. Ce sont 3 lignes de config Nginx pour proteger l'application.
|
||||
**Pourquoi :** sans `X-Frame-Options`, quelqu'un peut integrer Coltura dans une iframe sur un site tiers et faire du clickjacking (faire croire a l'utilisateur qu'il clique sur un bouton anodin alors qu'il valide une action dans Coltura). Sans `X-Content-Type-Options: nosniff`, un navigateur peut deviner le type MIME et executer un fichier qui n'aurait pas du l'etre. Ce sont 3 lignes de config Nginx pour proteger l'application.
|
||||
|
||||
**A faire :**
|
||||
|
||||
1. Ouvrir `infra/prod/nginx-proxy.conf` (c'est le proxy expose au public).
|
||||
2. Ajouter juste apres `server_name starseed.malio-dev.fr;` :
|
||||
2. Ajouter juste apres `server_name coltura.malio-dev.fr;` :
|
||||
|
||||
```nginx
|
||||
# En-tetes de securite applicables a toutes les reponses
|
||||
@@ -62,13 +62,13 @@ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
```
|
||||
|
||||
Explication :
|
||||
- `X-Frame-Options: DENY` : personne ne peut mettre Starseed dans une iframe.
|
||||
- `X-Frame-Options: DENY` : personne ne peut mettre Coltura dans une iframe.
|
||||
- `X-Content-Type-Options: nosniff` : le navigateur ne devine pas les types MIME, il fait confiance a ce que le serveur annonce.
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin` : limite ce que Starseed envoie comme Referer a des sites externes (evite de leaker `/admin/users/42` a un site tiers).
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin` : limite ce que Coltura envoie comme Referer a des sites externes (evite de leaker `/admin/users/42` a un site tiers).
|
||||
- `always` : envoyer ces en-tetes meme sur les reponses d'erreur (4xx/5xx).
|
||||
|
||||
3. Recharger Nginx : `docker restart nginx-starseed` (ou celui qui fait office de proxy public).
|
||||
4. Verifier : `curl -I https://starseed.malio-dev.fr/` doit afficher ces trois en-tetes.
|
||||
3. Recharger Nginx : `docker restart nginx-coltura` (ou celui qui fait office de proxy public).
|
||||
4. Verifier : `curl -I https://coltura.malio-dev.fr/` doit afficher ces trois en-tetes.
|
||||
|
||||
**Note :** si un reverse proxy externe (Traefik, Cloudflare) ajoute deja ces en-tetes, les poser ici ne fait que dupliquer, c'est sans risque (meme valeur).
|
||||
|
||||
@@ -813,7 +813,7 @@ if (!is_array($payload)) {
|
||||
```markdown
|
||||
# Changelog
|
||||
|
||||
Liste des evolutions du projet Starseed.
|
||||
Liste des evolutions du projet Coltura.
|
||||
|
||||
## [0.1.34] - 2026-04-XX
|
||||
|
||||
@@ -859,7 +859,7 @@ Liste des evolutions du projet Starseed.
|
||||
|
||||
### T-019 — Conditionner `loadSidebar()` apres switch de site
|
||||
|
||||
**Pourquoi :** apres chaque switch de site, `useCurrentSite` recharge la sidebar — mais la sidebar de Starseed ne depend d'aucun site. C'est un aller-retour reseau inutile par switch (~100ms + possible flicker visuel).
|
||||
**Pourquoi :** apres chaque switch de site, `useCurrentSite` recharge la sidebar — mais la sidebar de Coltura ne depend d'aucun site. C'est un aller-retour reseau inutile par switch (~100ms + possible flicker visuel).
|
||||
|
||||
**A faire :**
|
||||
|
||||
|
||||
+2
-6
@@ -12,12 +12,10 @@
|
||||
"doctrine/doctrine-bundle": "^3.2",
|
||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||
"doctrine/orm": "^3.6",
|
||||
"dompdf/dompdf": "^3.1",
|
||||
"lexik/jwt-authentication-bundle": "^3.2",
|
||||
"nelmio/cors-bundle": "^2.6",
|
||||
"nyholm/psr7": "^1.8",
|
||||
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
|
||||
"phpoffice/phpspreadsheet": "^5.7",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
"symfony/asset": "8.0.*",
|
||||
"symfony/console": "8.0.*",
|
||||
@@ -25,8 +23,6 @@
|
||||
"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",
|
||||
"symfony/property-access": "8.0.*",
|
||||
@@ -35,7 +31,6 @@
|
||||
"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.*",
|
||||
@@ -97,6 +92,7 @@
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||
"friendsofphp/php-cs-fixer": "^3.94",
|
||||
"phpunit/phpunit": "^13.0",
|
||||
"symfony/browser-kit": "8.0.*"
|
||||
"symfony/browser-kit": "8.0.*",
|
||||
"symfony/http-client": "8.0.*"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+254
-1226
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
use App\Module\Catalog\CatalogModule;
|
||||
use App\Module\Commercial\CommercialModule;
|
||||
use App\Module\Core\CoreModule;
|
||||
use App\Module\FieldSales\FieldSalesModule;
|
||||
use App\Module\Sites\SitesModule;
|
||||
|
||||
return [
|
||||
CoreModule::class,
|
||||
CommercialModule::class,
|
||||
SitesModule::class,
|
||||
CatalogModule::class,
|
||||
FieldSalesModule::class,
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
api_platform:
|
||||
title: Starseed API
|
||||
title: Coltura API
|
||||
version: 1.0.0
|
||||
# Scan du module Core pour decouvrir les classes ApiResource et ApiFilter.
|
||||
# Ajouter un chemin par module lors de l'ajout d'entites ApiResource dans d'autres modules.
|
||||
@@ -12,11 +12,6 @@ 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'
|
||||
# Module FieldSales (M6) : entites ApiResource Tour / TourStop.
|
||||
- '%kernel.project_dir%/src/Module/FieldSales/Domain/Entity'
|
||||
# Module FieldSales (M6) : resources virtuelles sans entite Doctrine
|
||||
# (VisitableTierResource — pins de la carte, lecture DBAL).
|
||||
- '%kernel.project_dir%/src/Module/FieldSales/Infrastructure/ApiPlatform/Resource'
|
||||
formats:
|
||||
jsonld: ['application/ld+json']
|
||||
json: ['application/json']
|
||||
@@ -26,18 +21,3 @@ api_platform:
|
||||
stateless: true
|
||||
cache_headers:
|
||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
||||
# === Pagination Hydra (regle projet : toute collection DOIT etre paginee) ===
|
||||
# Standard datatable : 10 items par defaut, choix client 10 / 25 / 50.
|
||||
# Borne dure cote serveur a 50 pour prevenir tout `?itemsPerPage=999999`
|
||||
# (attaque memoire / deep-fetch). Le client peut neanmoins desactiver la
|
||||
# pagination via `?pagination=false` pour alimenter un <select> ou autre
|
||||
# vue "tout-en-un" — c'est l'echappatoire prevue pour les ressources
|
||||
# servant a la fois de datatable et de source de select (Role,
|
||||
# Permission, Site, CategoryType). Override par ressource possible via
|
||||
# `paginationItemsPerPage` / `paginationMaximumItemsPerPage` /
|
||||
# `paginationEnabled` sur l'attribut #[ApiResource] ou sur une operation.
|
||||
pagination_enabled: true
|
||||
pagination_items_per_page: 10
|
||||
pagination_maximum_items_per_page: 50
|
||||
pagination_client_items_per_page: true
|
||||
pagination_client_enabled: true
|
||||
|
||||
@@ -33,21 +33,6 @@ doctrine:
|
||||
# `App\Module\Sites\Domain\Entity\Site` dans User.php.
|
||||
resolve_target_entities:
|
||||
App\Shared\Domain\Contract\SiteInterface: App\Module\Sites\Domain\Entity\Site
|
||||
# Cible des ManyToOne created_by / updated_by du TimestampableBlamableTrait.
|
||||
# Permet a Shared de referencer UserInterface dans ses ORM mappings sans
|
||||
# importer la classe concrete du module Core (cf. spec-back M0 § 2.8).
|
||||
Symfony\Component\Security\Core\User\UserInterface: App\Module\Core\Domain\Entity\User
|
||||
# Cible des ManyToMany Client.categories / ClientAddress.categories (M1).
|
||||
# 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
|
||||
# NOTE (M6 / VisitableInterface) : VisitableInterface n'apparait PAS ici.
|
||||
# resolve_target_entities mappe un contrat -> UNE seule classe concrete,
|
||||
# or ce contrat a plusieurs implementations (Client M1, Supplier M2, et
|
||||
# Prestataire a venir). FieldSales ne reference donc pas un Tiers via une
|
||||
# association Doctrine mais via le couple polymorphe (tier_type, tier_id)
|
||||
# de tour_stop, resolu par un service a partir de getVisitableType()
|
||||
# (ERP-124). Aucune ligne resolve_target_entities n'est requise/possible.
|
||||
mappings:
|
||||
Core:
|
||||
type: attribute
|
||||
@@ -65,38 +50,6 @@ doctrine:
|
||||
dir: '%kernel.project_dir%/src/Module/Sites/Domain/Entity'
|
||||
prefix: 'App\Module\Sites\Domain\Entity'
|
||||
alias: Sites
|
||||
# Mapping inconditionnel du module Catalog (meme logique que Sites) :
|
||||
# la structure DB (category, category_type) existe meme si
|
||||
# CatalogModule::class n'est pas encore wire dans config/modules.php
|
||||
# (declaration du module = ticket 0.5 / ERP-47). L'ORM doit connaitre
|
||||
# les entites pour que le schema soit en phase ; l'activation
|
||||
# fonctionnelle passe exclusivement par config/modules.php.
|
||||
Catalog:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Catalog/Domain/Entity'
|
||||
prefix: 'App\Module\Catalog\Domain\Entity'
|
||||
alias: Catalog
|
||||
# Mapping inconditionnel du module Commercial (meme logique que Catalog) :
|
||||
# les tables (client, sous-collections, referentiels comptables) creees
|
||||
# par la migration M1 (Version20260601000000) doivent etre connues de
|
||||
# l'ORM. L'activation fonctionnelle passe par config/modules.php.
|
||||
Commercial:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity'
|
||||
prefix: 'App\Module\Commercial\Domain\Entity'
|
||||
alias: Commercial
|
||||
# Mapping inconditionnel du module FieldSales (M6 — meme logique que
|
||||
# Commercial) : les tables tour / tour_stop creees par la migration
|
||||
# M6.3 (Version20260611140000) doivent etre connues de l'ORM.
|
||||
# L'activation fonctionnelle passe par config/modules.php.
|
||||
FieldSales:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/FieldSales/Domain/Entity'
|
||||
prefix: 'App\Module\FieldSales\Domain\Entity'
|
||||
alias: FieldSales
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
|
||||
@@ -3,14 +3,6 @@ lexik_jwt_authentication:
|
||||
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
|
||||
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
||||
token_ttl: '%env(int:JWT_TOKEN_TTL)%'
|
||||
# Tolerance d'horloge (en secondes) appliquee a la validation des claims
|
||||
# temporels iat / nbf / exp (LooseValidAt cote lcobucci). Sans cette marge
|
||||
# (defaut 0), un recul d'horloge entre la signature (/login_check) et la
|
||||
# requete suivante rend iat/nbf « dans le futur » -> « Invalid JWT Token »
|
||||
# (401). Observe en dev sous WSL2/Docker (horloge CLOCK_REALTIME non
|
||||
# monotone) : flakes intermittents de la suite PHPUnit (ERP-98). Benefice
|
||||
# aussi en prod si les noeuds derivent legerement entre eux.
|
||||
clock_skew: 15
|
||||
remove_token_from_body_when_cookies_used: true
|
||||
token_extractors:
|
||||
authorization_header:
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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
|
||||
@@ -1,12 +0,0 @@
|
||||
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:
|
||||
@@ -1,8 +1,6 @@
|
||||
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
|
||||
|
||||
parameters:
|
||||
# Vitesse moyenne (km/h) du moteur de trajet V1 Haversine (M6 § 3.4).
|
||||
field_sales.route_average_speed_kmh: 50.0
|
||||
|
||||
imports:
|
||||
- { resource: version.yaml }
|
||||
@@ -35,25 +33,3 @@ services:
|
||||
|
||||
App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
|
||||
alias: App\Module\Sites\Application\Service\CurrentSiteProvider
|
||||
|
||||
# Geocodage des adresses Tiers (M6.1) : BAN api-adresse.data.gouv.fr.
|
||||
App\Shared\Domain\Contract\GeocoderInterface:
|
||||
alias: App\Shared\Infrastructure\Geocoding\BanGeocoder
|
||||
|
||||
# Moteur de trajet V1 (M6 § 3.4) : Haversine + plus proche voisin. La V2
|
||||
# rebranchera OrsRouteEngine ici sans toucher au calculateur ni au front.
|
||||
App\Module\FieldSales\Domain\Route\RouteEngineInterface:
|
||||
alias: App\Module\FieldSales\Infrastructure\Route\HaversineRouteEngine
|
||||
|
||||
# Rendu PDF (feuille de route M6.4, etc.) : Dompdf.
|
||||
App\Shared\Domain\Contract\PdfRendererInterface:
|
||||
alias: App\Shared\Infrastructure\Pdf\DompdfRenderer
|
||||
|
||||
# En test : geocodeur en memoire, deterministe et sans reseau (les tests
|
||||
# fonctionnels d'adresse ne doivent jamais appeler la BAN reelle).
|
||||
when@test:
|
||||
services:
|
||||
App\Tests\Fixtures\Geocoding\InMemoryGeocoder: ~
|
||||
|
||||
App\Shared\Domain\Contract\GeocoderInterface:
|
||||
alias: App\Tests\Fixtures\Geocoding\InMemoryGeocoder
|
||||
|
||||
+12
-47
@@ -38,46 +38,6 @@ declare(strict_types=1);
|
||||
*/
|
||||
|
||||
return [
|
||||
// Section "Commerciale" : pole metier principal, remontee en tete de sidebar (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 "Tournées" (module field_sales, M6) : planification de tournees
|
||||
// commerciales terrain. Transverse Clients/Fournisseurs. Masquee si le module
|
||||
// field_sales est desactivee (cle `module`) ou si l'user n'a pas la
|
||||
// permission field_sales.tours.view.
|
||||
[
|
||||
'label' => 'sidebar.field_sales.section',
|
||||
'icon' => 'mdi:map-marker-path',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.field_sales.tours',
|
||||
'to' => '/tours',
|
||||
'icon' => 'mdi:map-marker-path',
|
||||
'module' => 'field_sales',
|
||||
'permission' => 'field_sales.tours.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Administration" : regroupe toutes les pages de configuration
|
||||
// applicative (RBAC, users, sites, audit log).
|
||||
//
|
||||
@@ -123,13 +83,6 @@ return [
|
||||
'module' => 'sites',
|
||||
'permission' => 'sites.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.catalog.categories',
|
||||
'to' => '/admin/categories',
|
||||
'icon' => 'mdi:tag-multiple-outline',
|
||||
'module' => 'catalog',
|
||||
'permission' => 'catalog.categories.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.core.audit_log',
|
||||
'to' => '/admin/audit-log',
|
||||
@@ -139,6 +92,18 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'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',
|
||||
],
|
||||
],
|
||||
],
|
||||
// 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.109'
|
||||
app.version: '0.1.34'
|
||||
|
||||
@@ -210,7 +210,7 @@ Le `requestId` est set en `kernel.request` mais jamais cleared. En deploiement F
|
||||
|
||||
**Fichiers** : `config/packages/framework.yaml`, `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php:69`
|
||||
|
||||
Aucune entree `trusted_proxies` ni env `TRUSTED_PROXIES`. Starseed tourne derriere `nginx-starseed` → `php-starseed-fpm`. `Request::getClientIp()` retourne donc systematiquement l'IP **du conteneur nginx** (reseau Docker interne), pas l'IP reelle du client. Toute la valeur forensique de `ip_address` est nulle en prod.
|
||||
Aucune entree `trusted_proxies` ni env `TRUSTED_PROXIES`. Coltura tourne derriere `nginx-coltura` → `php-coltura-fpm`. `Request::getClientIp()` retourne donc systematiquement l'IP **du conteneur nginx** (reseau Docker interne), pas l'IP reelle du client. Toute la valeur forensique de `ip_address` est nulle en prod.
|
||||
|
||||
Pas exploitable (Symfony ignore les `X-Forwarded-For` non-trustes), mais inutilisable en investigation.
|
||||
|
||||
|
||||
+28
-28
@@ -1,4 +1,4 @@
|
||||
# Deploiement Docker — Starseed
|
||||
# Deploiement Docker — Coltura
|
||||
|
||||
## Pre-requis
|
||||
|
||||
@@ -29,9 +29,9 @@ sudo systemctl start nginx
|
||||
### PostgreSQL
|
||||
|
||||
PostgreSQL tourne dans un conteneur Docker separe (voir le repo `infra-postgres`).
|
||||
Il doit etre installe et accessible avant de deployer Starseed.
|
||||
Il doit etre installe et accessible avant de deployer Coltura.
|
||||
|
||||
Creer la base de donnees pour Starseed :
|
||||
Creer la base de donnees pour Coltura :
|
||||
|
||||
```bash
|
||||
cd /var/www/postgres
|
||||
@@ -43,7 +43,7 @@ docker compose exec postgres psql -U admin
|
||||
CREATE USER malio WITH PASSWORD 'motdepasse';
|
||||
|
||||
-- Creer la base
|
||||
CREATE DATABASE starseed_prod OWNER malio;
|
||||
CREATE DATABASE coltura_prod OWNER malio;
|
||||
\q
|
||||
```
|
||||
|
||||
@@ -51,7 +51,7 @@ CREATE DATABASE starseed_prod OWNER malio;
|
||||
|
||||
## Premiere installation (nouvelle machine)
|
||||
|
||||
Guide complet pour mettre en ligne Starseed sur une machine vierge. Inclut les pre-requis, la BDD et l'app.
|
||||
Guide complet pour mettre en ligne Coltura sur une machine vierge. Inclut les pre-requis, la BDD et l'app.
|
||||
|
||||
### 1. Installer les pre-requis
|
||||
|
||||
@@ -60,9 +60,9 @@ Installer Docker, Nginx et PostgreSQL (voir section Pre-requis ci-dessus).
|
||||
### 2. Creer le dossier de deploiement
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/www/starseed
|
||||
sudo chown -R $(whoami):$(whoami) /var/www/starseed
|
||||
cd /var/www/starseed
|
||||
sudo mkdir -p /var/www/coltura
|
||||
sudo chown -R $(whoami):$(whoami) /var/www/coltura
|
||||
cd /var/www/coltura
|
||||
```
|
||||
|
||||
### 3. Se connecter au registry Docker de Gitea
|
||||
@@ -83,8 +83,8 @@ Creer `docker-compose.yml` :
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: gitea.malio.fr/malio-dev/starseed:${STARSEED_IMAGE_TAG:-latest}
|
||||
container_name: starseed-app
|
||||
image: gitea.malio.fr/malio-dev/coltura:${COLTURA_IMAGE_TAG:-latest}
|
||||
container_name: coltura-app
|
||||
env_file: .env
|
||||
ports:
|
||||
- "8083:80"
|
||||
@@ -105,9 +105,9 @@ set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TAG="${1:-latest}"
|
||||
export STARSEED_IMAGE_TAG="$TAG"
|
||||
export COLTURA_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying starseed:${TAG}..."
|
||||
echo "==> Deploying coltura:${TAG}..."
|
||||
|
||||
echo "==> Pulling image..."
|
||||
docker compose pull
|
||||
@@ -146,22 +146,22 @@ APP_DEBUG=0
|
||||
APP_SECRET=<generer avec: openssl rand -hex 32>
|
||||
|
||||
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
|
||||
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/starseed_prod?serverVersion=16&charset=utf8"
|
||||
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/coltura_prod?serverVersion=16&charset=utf8"
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
|
||||
JWT_COOKIE_SECURE=0
|
||||
JWT_COOKIE_SECURE=1
|
||||
JWT_COOKIE_SAMESITE=lax
|
||||
JWT_TOKEN_TTL=86400
|
||||
JWT_COOKIE_TTL=86400
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_ORIGIN='^https?://starseed\.malio-dev\.fr$'
|
||||
CORS_ALLOW_ORIGIN='^https?://coltura\.malio-dev\.fr$'
|
||||
|
||||
# App
|
||||
DEFAULT_URI=http://starseed.malio-dev.fr
|
||||
DEFAULT_URI=https://coltura.malio-dev.fr
|
||||
```
|
||||
|
||||
### 6. Generer les cles JWT
|
||||
@@ -190,17 +190,17 @@ mkdir -p uploads
|
||||
Copier la config reverse proxy depuis le repo :
|
||||
|
||||
```bash
|
||||
sudo cp infra/prod/nginx-proxy.conf /etc/nginx/sites-available/starseed.conf
|
||||
sudo cp infra/prod/nginx-proxy.conf /etc/nginx/sites-available/coltura.conf
|
||||
```
|
||||
|
||||
Ou creer `/etc/nginx/sites-available/starseed.conf` manuellement (voir `infra/prod/nginx-proxy.conf`).
|
||||
Ou creer `/etc/nginx/sites-available/coltura.conf` manuellement (voir `infra/prod/nginx-proxy.conf`).
|
||||
|
||||
La config inclut le **mode maintenance** : si le fichier `/var/www/starseed/maintenance.on` existe, Nginx renvoie une 503 avec `maintenance.html`.
|
||||
La config inclut le **mode maintenance** : si le fichier `/var/www/coltura/maintenance.on` existe, Nginx renvoie une 503 avec `maintenance.html`.
|
||||
|
||||
Activer le site :
|
||||
|
||||
```bash
|
||||
sudo ln -sf /etc/nginx/sites-available/starseed.conf /etc/nginx/sites-enabled/starseed.conf
|
||||
sudo ln -sf /etc/nginx/sites-available/coltura.conf /etc/nginx/sites-enabled/coltura.conf
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
@@ -208,13 +208,13 @@ sudo nginx -t && sudo systemctl reload nginx
|
||||
|
||||
```bash
|
||||
# Activer la maintenance
|
||||
touch /var/www/starseed/maintenance.on
|
||||
touch /var/www/coltura/maintenance.on
|
||||
|
||||
# Desactiver la maintenance
|
||||
rm /var/www/starseed/maintenance.on
|
||||
rm /var/www/coltura/maintenance.on
|
||||
```
|
||||
|
||||
Optionnel : creer une page `/var/www/starseed/public/maintenance.html` personnalisee.
|
||||
Optionnel : creer une page `/var/www/coltura/public/maintenance.html` personnalisee.
|
||||
|
||||
### 9. Deployer
|
||||
|
||||
@@ -232,7 +232,7 @@ Choisir `App\Entity\User`, taper le mdp, copier le hash. Puis :
|
||||
|
||||
```bash
|
||||
cd /var/www/postgres
|
||||
docker compose exec -T postgres psql -U malio starseed_prod -c "INSERT INTO \"user\" (username, roles, password, created_at) VALUES ('admin', '[\"ROLE_ADMIN\"]', '<le-hash>', NOW());"
|
||||
docker compose exec -T postgres psql -U malio coltura_prod -c "INSERT INTO \"user\" (username, roles, password, created_at) VALUES ('admin', '[\"ROLE_ADMIN\"]', '<le-hash>', NOW());"
|
||||
```
|
||||
|
||||
Ou charger les fixtures (dev uniquement) :
|
||||
@@ -244,7 +244,7 @@ docker compose exec -T -u www-data app php bin/console doctrine:fixtures:load --
|
||||
### Structure finale du dossier
|
||||
|
||||
```
|
||||
/var/www/starseed/
|
||||
/var/www/coltura/
|
||||
├── docker-compose.yml
|
||||
├── deploy.sh
|
||||
├── .env
|
||||
@@ -261,7 +261,7 @@ docker compose exec -T -u www-data app php bin/console doctrine:fixtures:load --
|
||||
Quand l'app est deja installee, deployer une mise a jour :
|
||||
|
||||
```bash
|
||||
cd /var/www/starseed
|
||||
cd /var/www/coltura
|
||||
./deploy.sh # deploie la derniere version (latest)
|
||||
./deploy.sh v0.2.0 # deploie une version specifique
|
||||
```
|
||||
@@ -293,7 +293,7 @@ docker compose exec -T -u www-data app php bin/console doctrine:migrations:migra
|
||||
|
||||
Le workflow `.gitea/workflows/build-docker.yml` se declenche automatiquement sur push de tag `v*` :
|
||||
1. Build l'image multi-stage
|
||||
2. Push vers `gitea.malio.fr/malio-dev/starseed:<tag>` et `:latest`
|
||||
2. Push vers `gitea.malio.fr/malio-dev/coltura:<tag>` et `:latest`
|
||||
|
||||
Combine avec `auto-tag-develop.yml`, chaque push sur `develop` cree automatiquement un tag → build → image disponible.
|
||||
|
||||
@@ -302,7 +302,7 @@ Combine avec `auto-tag-develop.yml`, chaque push sur `develop` cree automatiquem
|
||||
## Voir les logs
|
||||
|
||||
```bash
|
||||
cd /var/www/starseed
|
||||
cd /var/www/coltura
|
||||
docker compose logs -f # tous les logs
|
||||
docker compose logs -f --tail=100 # 100 dernieres lignes
|
||||
```
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
# Prompt — Migration prod Coltura -> Starseed
|
||||
|
||||
Copier-coller integralement dans une session Claude lancee **sur le serveur de prod** apres que :
|
||||
- le push develop + build CI ont publie l'image `gitea.malio.fr/malio-dev/starseed:latest`,
|
||||
- la resolution reseau local (DNS interne ou `/etc/hosts` des postes clients) pour `starseed.malio-dev.fr` est en place.
|
||||
|
||||
> Setup : HTTP en reseau local, pas de TLS. Pas de Let's Encrypt.
|
||||
|
||||
---
|
||||
|
||||
## Prompt a fournir au Claude prod
|
||||
|
||||
Tu es sur le serveur de production d'une app Symfony+Nuxt qui s'appelait **Coltura** et qui doit etre renommee en **Starseed**. Le rename cote code est deja fait et merge. Le repo Gitea s'appelle deja `starseed`. L'image `gitea.malio.fr/malio-dev/starseed:latest` est publiee.
|
||||
|
||||
L'app est servie en **HTTP sur reseau local** (pas de TLS, pas de Let's Encrypt). La resolution `starseed.malio-dev.fr` est faite via DNS interne ou `/etc/hosts` cote postes clients — pas de certificat a gerer.
|
||||
|
||||
Objectif : basculer la prod sur le nouveau nom (registry, container, DB, path FS, vhost) **sans perdre les donnees** et avec downtime minimal (mode maintenance pendant la migration).
|
||||
|
||||
**Etat actuel a verifier en premier** (donne-moi le retour de chaque commande avant de continuer) :
|
||||
|
||||
```bash
|
||||
# 1. Container actuel + image
|
||||
sudo docker ps --filter name=coltura-app --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
|
||||
|
||||
# 2. DB existante
|
||||
sudo -u postgres psql -c "\l" | grep -E "coltura|starseed"
|
||||
|
||||
# 3. Path FS app
|
||||
ls -la /var/www/coltura/ 2>/dev/null | head -5
|
||||
ls -la /var/www/starseed/ 2>/dev/null | head -5
|
||||
|
||||
# 4. Vhost nginx system
|
||||
sudo ls -la /etc/nginx/sites-enabled/ | grep -E "coltura|starseed"
|
||||
```
|
||||
|
||||
**Apres confirmation de l'etat, executer dans cet ordre, en demandant validation utilisateur AVANT chaque etape destructive (DB drop, rm -rf, certificat) :**
|
||||
|
||||
### Etape 1 — Mode maintenance
|
||||
|
||||
```bash
|
||||
cd /var/www/coltura
|
||||
touch maintenance.on
|
||||
# Verifier qu'une requete renvoie 503
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://coltura.malio-dev.fr/
|
||||
```
|
||||
|
||||
Doit renvoyer `503`.
|
||||
|
||||
### Etape 2 — Backup DB (CRITIQUE — ne pas skipper)
|
||||
|
||||
```bash
|
||||
BACKUP_FILE="/root/coltura_prod_backup_$(date +%Y%m%d_%H%M%S).sql"
|
||||
sudo -u postgres pg_dump -F c -f "$BACKUP_FILE" coltura_prod
|
||||
ls -lh "$BACKUP_FILE"
|
||||
```
|
||||
|
||||
**Stocker ce chemin** — il sera utilise pour le rollback.
|
||||
|
||||
### Etape 3 — Creer la DB cible et migrer
|
||||
|
||||
Recuperer l'owner et le user de connexion actuels :
|
||||
|
||||
```bash
|
||||
sudo -u postgres psql -c "\l coltura_prod"
|
||||
grep DATABASE_URL /var/www/coltura/.env
|
||||
```
|
||||
|
||||
Puis (adapter l'owner si different de `malio`) :
|
||||
|
||||
```bash
|
||||
sudo -u postgres psql <<'SQL'
|
||||
CREATE DATABASE starseed_prod OWNER malio;
|
||||
SQL
|
||||
|
||||
sudo -u postgres pg_dump coltura_prod | sudo -u postgres psql starseed_prod
|
||||
sudo -u postgres psql starseed_prod -c "\dt" | head -20
|
||||
```
|
||||
|
||||
Verifier que les tables sont bien copiees. Si le user PG s'appelle `coltura`, le renommer ou en creer un `starseed` est OPTIONNEL — la connexion peut continuer avec `coltura` tant que `GRANT` est OK. **Confirmer avec l'utilisateur** s'il veut renommer le role PG :
|
||||
|
||||
```bash
|
||||
# Optionnel : renommer le role PG (si user de connexion s'appelle 'coltura')
|
||||
# sudo -u postgres psql -c "ALTER ROLE coltura RENAME TO starseed;"
|
||||
```
|
||||
|
||||
### Etape 4 — Renommer le path FS
|
||||
|
||||
```bash
|
||||
sudo mv /var/www/coltura /var/www/starseed
|
||||
# Verifier le contenu
|
||||
sudo ls -la /var/www/starseed/ | head -10
|
||||
# Verifier que .env existe encore
|
||||
sudo test -f /var/www/starseed/.env && echo ".env OK"
|
||||
```
|
||||
|
||||
### Etape 5 — Mettre a jour .env de prod
|
||||
|
||||
Editer `/var/www/starseed/.env` :
|
||||
- `DATABASE_URL` : remplacer `/coltura_prod` -> `/starseed_prod` (et user si renomme a etape 3)
|
||||
- `CORS_ALLOW_ORIGIN` : remplacer `coltura.malio-dev.fr` -> `starseed.malio-dev.fr`
|
||||
- `DEFAULT_URI` : `http://starseed.malio-dev.fr`
|
||||
- `JWT_COOKIE_SECURE` : doit etre `0` (HTTP, pas de TLS) — verifier qu'il l'est deja
|
||||
|
||||
Diff attendu :
|
||||
|
||||
```diff
|
||||
- DATABASE_URL="postgresql://malio:xxx@host.docker.internal:5432/coltura_prod?..."
|
||||
+ DATABASE_URL="postgresql://malio:xxx@host.docker.internal:5432/starseed_prod?..."
|
||||
- CORS_ALLOW_ORIGIN='^http://coltura\.malio-dev\.fr$'
|
||||
+ CORS_ALLOW_ORIGIN='^http://starseed\.malio-dev\.fr$'
|
||||
- DEFAULT_URI=http://coltura.malio-dev.fr
|
||||
+ DEFAULT_URI=http://starseed.malio-dev.fr
|
||||
```
|
||||
|
||||
### Etape 6 — Stopper et supprimer l'ancien container
|
||||
|
||||
```bash
|
||||
cd /var/www/starseed
|
||||
sudo docker compose down
|
||||
# Verifier qu'il n'y a plus de coltura-app
|
||||
sudo docker ps -a --filter name=coltura
|
||||
```
|
||||
|
||||
### Etape 7 — Pull la nouvelle image et demarrer
|
||||
|
||||
Le `docker-compose.prod.yml` du dossier deja a jour pointe sur `gitea.malio.fr/malio-dev/starseed:latest` et `container_name: starseed-app`.
|
||||
|
||||
```bash
|
||||
cd /var/www/starseed
|
||||
sudo docker compose pull
|
||||
sudo docker compose up -d
|
||||
sleep 5
|
||||
sudo docker ps --filter name=starseed-app
|
||||
sudo docker logs starseed-app --tail 30
|
||||
```
|
||||
|
||||
### Etape 8 — Migrations Doctrine + cache
|
||||
|
||||
```bash
|
||||
cd /var/www/starseed
|
||||
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
```
|
||||
|
||||
### Etape 9 — Vhost nginx system (HTTP only)
|
||||
|
||||
Copier le nouveau vhost (a jour avec `server_name starseed.malio-dev.fr` et `root /var/www/starseed/public`, `listen 80` uniquement) :
|
||||
|
||||
```bash
|
||||
sudo cp /var/www/starseed/infra/prod/nginx-proxy.conf /etc/nginx/sites-available/starseed.conf
|
||||
sudo ln -sf /etc/nginx/sites-available/starseed.conf /etc/nginx/sites-enabled/starseed.conf
|
||||
sudo rm -f /etc/nginx/sites-enabled/coltura.conf
|
||||
sudo nginx -t
|
||||
```
|
||||
|
||||
Verifier la resolution reseau local avant reload :
|
||||
|
||||
```bash
|
||||
getent hosts starseed.malio-dev.fr || echo "ATTENTION : starseed.malio-dev.fr ne resout pas localement"
|
||||
```
|
||||
|
||||
Puis :
|
||||
|
||||
```bash
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### Etape 10 — Desactiver le mode maintenance et tester
|
||||
|
||||
```bash
|
||||
rm -f /var/www/starseed/maintenance.on
|
||||
|
||||
# Tests externes (HTTP local)
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://starseed.malio-dev.fr/
|
||||
curl -s http://starseed.malio-dev.fr/api/version
|
||||
```
|
||||
|
||||
`/api/version` doit renvoyer du JSON avec la version courante.
|
||||
|
||||
### Etape 11 — Cleanup (apres 24-48h de stabilite)
|
||||
|
||||
A faire **plus tard**, seulement quand on est sur que tout marche :
|
||||
|
||||
```bash
|
||||
# Backup deja conserve en /root/coltura_prod_backup_*.sql.
|
||||
# Apres validation utilisateur :
|
||||
sudo -u postgres psql -c "DROP DATABASE coltura_prod;"
|
||||
sudo rm -f /etc/nginx/sites-available/coltura.conf
|
||||
sudo docker image prune # nettoie les vieilles images coltura
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback (si echec apres etape 5)
|
||||
|
||||
```bash
|
||||
# 1. Remettre maintenance
|
||||
touch /var/www/starseed/maintenance.on 2>/dev/null || touch /var/www/coltura/maintenance.on
|
||||
|
||||
# 2. Restaurer le path FS
|
||||
sudo mv /var/www/starseed /var/www/coltura 2>/dev/null || true
|
||||
|
||||
# 3. Restaurer le vhost coltura
|
||||
sudo rm -f /etc/nginx/sites-enabled/starseed.conf
|
||||
sudo ln -sf /etc/nginx/sites-available/coltura.conf /etc/nginx/sites-enabled/coltura.conf
|
||||
sudo systemctl reload nginx
|
||||
|
||||
# 4. Redemarrer l'ancien container (l'image coltura est encore dans le registry)
|
||||
cd /var/www/coltura
|
||||
# Editer docker-compose.prod.yml pour pointer sur coltura:latest si necessaire
|
||||
sudo docker compose up -d
|
||||
|
||||
# 5. Si la DB starseed_prod a ete modifiee, restaurer depuis le backup
|
||||
sudo -u postgres psql -c "DROP DATABASE IF EXISTS coltura_prod;"
|
||||
sudo -u postgres pg_restore -C -d postgres "$BACKUP_FILE"
|
||||
|
||||
# 6. Lever maintenance
|
||||
rm -f /var/www/coltura/maintenance.on
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Regles de comportement pour le Claude prod
|
||||
|
||||
- **Ne jamais skipper le backup** (etape 2).
|
||||
- **Demander confirmation utilisateur** avant : `DROP DATABASE`, `rm -rf`, et avant de lever le mode maintenance final.
|
||||
- **Une seule operation destructive a la fois**, attendre le retour utilisateur entre chaque.
|
||||
- **Logger systematiquement** la sortie des commandes critiques (pg_dump, docker compose up, nginx -t / reload).
|
||||
- **Si une etape echoue**, NE PAS continuer — declencher le rollback.
|
||||
- **Ne commit rien** sur le repo depuis le serveur prod.
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
## Résumé de la PR
|
||||
|
||||
Cette PR restructure Starseed (CRM/ERP) en **architecture modulaire DDD** (Domain-Driven Design) :
|
||||
Cette PR restructure Coltura (CRM/ERP) en **architecture modulaire DDD** (Domain-Driven Design) :
|
||||
|
||||
- **Backend** : introduction de bounded contexts (`Module/Core`, `Module/Commercial`) avec séparation Domain / Application / Infrastructure
|
||||
- **Shared** : couche partagée (events, value objects, contracts, bus interfaces)
|
||||
@@ -36,9 +36,9 @@ Cette PR restructure Starseed (CRM/ERP) en **architecture modulaire DDD** (Domai
|
||||
Liste des évolutions du projet Ferme
|
||||
```
|
||||
|
||||
Ce fichier appartient à **Starseed**, pas au projet Ferme. C'est une erreur de copier-coller lors du scaffolding initial.
|
||||
Ce fichier appartient à **Coltura**, pas au projet Ferme. C'est une erreur de copier-coller lors du scaffolding initial.
|
||||
|
||||
**Correction** : Remplacer "Ferme" par "Starseed".
|
||||
**Correction** : Remplacer "Ferme" par "Coltura".
|
||||
|
||||
---
|
||||
|
||||
@@ -76,10 +76,10 @@ Mais la seule page du module commercial est `frontend/modules/commercial/pages/c
|
||||
|---|---|
|
||||
| **Sévérité** | Majeure |
|
||||
| **Fichier** | `infra/dev/.env.docker` |
|
||||
| **Règle violée** | Workspace `CLAUDE.md` : "Starseed — 8083 / 3003 / **5436**" |
|
||||
| **Règle violée** | Workspace `CLAUDE.md` : "Coltura — 8083 / 3003 / **5436**" |
|
||||
| **Confiance** | 75/100 |
|
||||
|
||||
**Constat** : Le fichier `.env.docker` définit `POSTGRES_PORT=5437`, alors que le port documenté pour Starseed est `5436`.
|
||||
**Constat** : Le fichier `.env.docker` définit `POSTGRES_PORT=5437`, alors que le port documenté pour Coltura est `5436`.
|
||||
|
||||
**Impact** : Tout développeur qui suit les ports documentés (ou qui utilise des scripts basés sur ces ports) ne pourra pas se connecter à la base.
|
||||
|
||||
@@ -93,7 +93,7 @@ Mais la seule page du module commercial est `frontend/modules/commercial/pages/c
|
||||
|---|---|
|
||||
| **Sévérité** | Majeure |
|
||||
| **Fichiers** | `frontend/nuxt.config.ts` (ligne 40), `docker-compose.yml` (ligne 33) |
|
||||
| **Règle violée** | Workspace `CLAUDE.md` : "Starseed — 8083 / **3003** / 5436" et `CLAUDE.md` projet : "make dev-nuxt # port 3003" |
|
||||
| **Règle violée** | Workspace `CLAUDE.md` : "Coltura — 8083 / **3003** / 5436" et `CLAUDE.md` projet : "make dev-nuxt # port 3003" |
|
||||
| **Confiance** | 75/100 (confirmé par 3 agents indépendants) |
|
||||
|
||||
**Constat** :
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ services:
|
||||
- "8083:80"
|
||||
volumes:
|
||||
- ./:/var/www/html:ro
|
||||
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/coltura.conf:ro
|
||||
restart: unless-stopped
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
|
||||
@@ -13,7 +13,7 @@ Ce ticket livre la base RBAC backend de l'epic en 5 tickets en remplacant le sto
|
||||
- Faire evoluer `User` avec une relation ManyToMany vers `Role`, une relation ManyToMany vers `Permission` pour les permissions directes et un booleen `is_admin`.
|
||||
- Faire evoluer `User::getRoles()` pour rester compatible Symfony en retournant toujours `ROLE_USER` et `ROLE_ADMIN` si `is_admin = true`.
|
||||
- Ajouter `User::getEffectivePermissions()` pour retourner l'union des codes de permissions provenant des roles et des permissions directes.
|
||||
- Ajouter une methode statique `permissions()` sur `/home/matthieu/dev_malio/Starseed/src/Module/Core/CoreModule.php` et definir le pattern a reproduire pour les autres modules.
|
||||
- Ajouter une methode statique `permissions()` sur `/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php` et definir le pattern a reproduire pour les autres modules.
|
||||
- Ajouter une commande console `app:sync-permissions` transactionnelle, idempotente et non destructive avec gestion `orphan`.
|
||||
- Ajouter une migration Doctrine modulaire Core qui cree les tables RBAC, migre les donnees depuis `user.roles`, cree les roles systeme `admin` et `user`, puis supprime la colonne JSON `roles`.
|
||||
- Mettre a jour les fixtures Core pour creer les roles systeme et rattacher l'utilisateur admin au role `admin`.
|
||||
@@ -31,30 +31,30 @@ Ce ticket livre la base RBAC backend de l'epic en 5 tickets en remplacant le sto
|
||||
|
||||
### Domaine - Entités
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/Permission.php` : entite Doctrine de permission RBAC, code unique, module source et etat `orphan`.
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/Role.php` : entite Doctrine de role RBAC avec relations vers permissions et garde de role systeme.
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php` : entite Doctrine de permission RBAC, code unique, module source et etat `orphan`.
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php` : entite Doctrine de role RBAC avec relations vers permissions et garde de role systeme.
|
||||
|
||||
### Domaine - Repositories
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php` : contrat de lecture/ecriture des permissions pour la commande de sync et les fixtures.
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php` : contrat de lecture/ecriture des roles pour migration fonctionnelle, fixtures et usages futurs.
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php` : contrat de lecture/ecriture des permissions pour la commande de sync et les fixtures.
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php` : contrat de lecture/ecriture des roles pour migration fonctionnelle, fixtures et usages futurs.
|
||||
|
||||
### Domaine - Exceptions
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php` : exception domaine levee si une suppression vise un role systeme.
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php` : exception domaine levee si une suppression vise un role systeme.
|
||||
|
||||
### Infrastructure - Doctrine
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php` : implementation Doctrine de `PermissionRepositoryInterface`.
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php` : implementation Doctrine de `RoleRepositoryInterface`.
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php` : implementation Doctrine de `PermissionRepositoryInterface`.
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php` : implementation Doctrine de `RoleRepositoryInterface`.
|
||||
|
||||
### Infrastructure - Doctrine Migrations
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` : migration modulaire RBAC Core avec schema + migration de donnees + rollback minimal.
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` : migration modulaire RBAC Core avec schema + migration de donnees + rollback minimal.
|
||||
|
||||
### Infrastructure - Console
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` : commande `app:sync-permissions` qui scanne les modules actifs et synchronise la table `permission`.
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` : commande `app:sync-permissions` qui scanne les modules actifs et synchronise la table `permission`.
|
||||
|
||||
### Infrastructure - DataFixtures
|
||||
|
||||
@@ -62,12 +62,12 @@ Ce ticket livre la base RBAC backend de l'epic en 5 tickets en remplacant le sto
|
||||
|
||||
### Constantes domaine
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Security/SystemRoles.php` : constantes partagees `ADMIN_CODE = 'admin'` et `USER_CODE = 'user'`, utilisees a la fois par les fixtures et par la migration SQL. Place dans `Domain/Security/` (pas `ValueObject/` : ce n'est pas un VO, c'est un conteneur de constantes metier laissant de la place pour d'autres constantes de securite plus tard).
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/SystemRoles.php` : constantes partagees `ADMIN_CODE = 'admin'` et `USER_CODE = 'user'`, utilisees a la fois par les fixtures et par la migration SQL. Place dans `Domain/Security/` (pas `ValueObject/` : ce n'est pas un VO, c'est un conteneur de constantes metier laissant de la place pour d'autres constantes de securite plus tard).
|
||||
|
||||
## 4. Fichiers à modifier
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/User.php` : supprimer le stockage JSON `roles`, ajouter `isAdmin`, `roles`, `directPermissions`, initialiser les collections, configurer les relations ManyToMany en `fetch=EAGER`, ajouter `getEffectivePermissions()` et adapter `getRoles()` / mutateurs.
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/CoreModule.php` : ajouter une methode statique `public static function permissions(): array` qui declare les permissions natives du module Core et sert de reference pour les autres modules. Contenu initial exact :
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php` : supprimer le stockage JSON `roles`, ajouter `isAdmin`, `roles`, `directPermissions`, initialiser les collections, configurer les relations ManyToMany en `fetch=EAGER`, ajouter `getEffectivePermissions()` et adapter `getRoles()` / mutateurs.
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php` : ajouter une methode statique `public static function permissions(): array` qui declare les permissions natives du module Core et sert de reference pour les autres modules. Contenu initial exact :
|
||||
```php
|
||||
public static function permissions(): array
|
||||
{
|
||||
@@ -80,17 +80,17 @@ Ce ticket livre la base RBAC backend de l'epic en 5 tickets en remplacant le sto
|
||||
}
|
||||
```
|
||||
La cle `module` n'est PAS presente dans le payload : elle est auto-injectee par la commande de sync a partir de `CoreModule::ID`. Le code de permission doit obligatoirement commencer par `self::ID . '.'` sous peine d'echec de la sync (garde anti-typo).
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php` : aucun changement attendu dans ce ticket. Les nouvelles relations `$roles`, `$directPermissions` sont chargees par Doctrine via leurs mappings `fetch=EAGER` declares sur l'entite. Si les tests d'integration revelent un lazy-load non voulu au refresh JWT ou a la desserialisation, ajouter une methode `findForSecurity(string $username): ?User` avec `leftJoin` + `addSelect` explicites sur `roles`, `roles.permissions`, `directPermissions`, et brancher le user provider dessus. A trancher par les tests, pas en prevention.
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Repository/UserRepositoryInterface.php` : aucun changement dans ce ticket. Ajout eventuel de `findForSecurity()` uniquement si le cas ci-dessus se materialise.
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` : remplacer l'usage de `setRoles(array)` par la creation des roles systeme, le rattachement des utilisateurs a ces roles et le positionnement de `is_admin`.
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Console/CreateUserCommand.php` : remplacer la gestion historique de `ROLE_ADMIN` par `setIsAdmin(true)` et rattachement au role systeme `admin` si l'option `--admin` est conservee.
|
||||
- `/home/matthieu/dev_malio/Starseed/config/services.yaml` : ajouter 2 alias repository, aligne sur le pattern existant pour `UserRepositoryInterface` :
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php` : aucun changement attendu dans ce ticket. Les nouvelles relations `$roles`, `$directPermissions` sont chargees par Doctrine via leurs mappings `fetch=EAGER` declares sur l'entite. Si les tests d'integration revelent un lazy-load non voulu au refresh JWT ou a la desserialisation, ajouter une methode `findForSecurity(string $username): ?User` avec `leftJoin` + `addSelect` explicites sur `roles`, `roles.permissions`, `directPermissions`, et brancher le user provider dessus. A trancher par les tests, pas en prevention.
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/UserRepositoryInterface.php` : aucun changement dans ce ticket. Ajout eventuel de `findForSecurity()` uniquement si le cas ci-dessus se materialise.
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` : remplacer l'usage de `setRoles(array)` par la creation des roles systeme, le rattachement des utilisateurs a ces roles et le positionnement de `is_admin`.
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/CreateUserCommand.php` : remplacer la gestion historique de `ROLE_ADMIN` par `setIsAdmin(true)` et rattachement au role systeme `admin` si l'option `--admin` est conservee.
|
||||
- `/home/matthieu/dev_malio/Coltura/config/services.yaml` : ajouter 2 alias repository, aligne sur le pattern existant pour `UserRepositoryInterface` :
|
||||
```yaml
|
||||
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
|
||||
App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository'
|
||||
```
|
||||
La commande `SyncPermissionsCommand` est auto-configuree via `autoconfigure: true`, aucun binding manuel necessaire.
|
||||
- `/home/matthieu/dev_malio/Starseed/config/modules.php` : aucun changement de contenu requis, mais la commande `app:sync-permissions` devra s'appuyer sur ce fichier comme source de verite des modules actifs.
|
||||
- `/home/matthieu/dev_malio/Coltura/config/modules.php` : aucun changement de contenu requis, mais la commande `app:sync-permissions` devra s'appuyer sur ce fichier comme source de verite des modules actifs.
|
||||
|
||||
## 5. Schéma cible — mappings Doctrine
|
||||
|
||||
@@ -209,7 +209,7 @@ Etat final attendu :
|
||||
|
||||
## 6. Plan de migration Doctrine
|
||||
|
||||
La migration doit etre implementée dans `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` et executer `up()` dans cet ordre.
|
||||
La migration doit etre implementée dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` et executer `up()` dans cet ordre.
|
||||
|
||||
**Workflow recommande** :
|
||||
1. Ecrire d'abord les entites `Permission`, `Role` et la mutation de `User` (section 5).
|
||||
@@ -287,7 +287,7 @@ Cas couverts explicitement :
|
||||
Le mapping Doctrine actuel (`array` PHP → default) peut avoir genere une colonne `JSON` OU `TEXT` selon la version de Symfony/Doctrine. Le cast `::jsonb` fonctionne directement sur `JSON`, mais pas sur `TEXT`. **Avant d'executer la migration en prod**, verifier avec :
|
||||
|
||||
```bash
|
||||
docker exec -it db-starseed psql -U malio -d starseed -c '\d "user"'
|
||||
docker exec -it db-coltura psql -U malio -d coltura -c '\d "user"'
|
||||
```
|
||||
|
||||
- Si `roles | json` : le SQL ci-dessus fonctionne tel quel.
|
||||
@@ -306,11 +306,11 @@ Le rollback ne restitue pas la granularite RBAC complete, ce qui est acceptable
|
||||
|
||||
## 7. Algorithme sync-permissions
|
||||
|
||||
La commande `app:sync-permissions` doit vivre dans `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` et encapsuler toute l'operation dans une transaction Doctrine unique.
|
||||
La commande `app:sync-permissions` doit vivre dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` et encapsuler toute l'operation dans une transaction Doctrine unique.
|
||||
|
||||
### Source de verite
|
||||
|
||||
- Le scan des modules actifs vient de `/home/matthieu/dev_malio/Starseed/config/modules.php`.
|
||||
- Le scan des modules actifs vient de `/home/matthieu/dev_malio/Coltura/config/modules.php`.
|
||||
- Chaque classe module active peut exposer `public static function permissions(): array`.
|
||||
- Par compatibilite montante, si une classe module n'expose pas encore `permissions()`, elle est traitee comme retournant `[]`.
|
||||
|
||||
@@ -330,7 +330,7 @@ Garde anti-typo : le sync command verifie que chaque `code` commence obligatoire
|
||||
```text
|
||||
begin transaction
|
||||
|
||||
load active module classes from /home/matthieu/dev_malio/Starseed/config/modules.php
|
||||
load active module classes from /home/matthieu/dev_malio/Coltura/config/modules.php
|
||||
desired_permissions = empty map keyed by code
|
||||
|
||||
for each module class:
|
||||
@@ -439,7 +439,7 @@ Repasse `orphan` a `false` et remet a jour les metadonnees issues de la declarat
|
||||
|
||||
## 9. Fixtures mises à jour
|
||||
|
||||
Le fichier cible reste `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php`.
|
||||
Le fichier cible reste `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php`.
|
||||
|
||||
### Principe cle : decouplage via `is_admin`
|
||||
|
||||
@@ -519,7 +519,7 @@ Les tests d'integration migration up/down exigent une base de test dediee avec u
|
||||
- Risque de perte de donnees pendant la suppression de la colonne `user.roles`.
|
||||
- Mitigation : creer les roles systeme et inserer les jointures `user_role` avant tout `DROP COLUMN`, avec tests de migration sur etats mixtes.
|
||||
- Risque de divergence entre migration SQL brute et fixtures sur les codes des roles systeme.
|
||||
- Mitigation : centraliser `admin` et `user` dans `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Security/SystemRoles.php` et documenter que la migration doit reprendre ces valeurs telles quelles.
|
||||
- Mitigation : centraliser `admin` et `user` dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/SystemRoles.php` et documenter que la migration doit reprendre ces valeurs telles quelles.
|
||||
- Risque d'accumulation de permissions orphelines sur des environnements de dev ou apres refactors de codes.
|
||||
- Mitigation : conserver `orphan = true` pour la non-destruction, mais ajouter un suivi explicite dans les tests et dans la documentation d'exploitation; une strategie de purge pourra etre traitee plus tard si necessaire.
|
||||
- Risque de sync incoherente entre dev et prod si un module actif ne declare pas encore `permissions()`.
|
||||
@@ -533,12 +533,12 @@ Les tests d'integration migration up/down exigent une base de test dediee avec u
|
||||
|
||||
1. Creer `Permission`, `Role`, `SystemRoleDeletionException` et `SystemRoles`.
|
||||
2. Creer `PermissionRepositoryInterface`, `RoleRepositoryInterface` et leurs implementations Doctrine.
|
||||
3. Faire evoluer `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/User.php` avec `is_admin`, `roles`, `directPermissions`, `getRoles()` et `getEffectivePermissions()`.
|
||||
3. Faire evoluer `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php` avec `is_admin`, `roles`, `directPermissions`, `getRoles()` et `getEffectivePermissions()`.
|
||||
4. Ajouter `CoreModule::permissions()` et documenter le pattern de declaration statique pour les autres modules.
|
||||
5. Ajouter la commande `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php`.
|
||||
6. Ecrire la migration `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` avec schema + migration de donnees + down().
|
||||
7. Mettre a jour `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` et `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Console/CreateUserCommand.php`.
|
||||
8. Ajouter les alias repository dans `/home/matthieu/dev_malio/Starseed/config/services.yaml`.
|
||||
5. Ajouter la commande `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php`.
|
||||
6. Ecrire la migration `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` avec schema + migration de donnees + down().
|
||||
7. Mettre a jour `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` et `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/CreateUserCommand.php`.
|
||||
8. Ajouter les alias repository dans `/home/matthieu/dev_malio/Coltura/config/services.yaml`.
|
||||
9. Ecrire les tests unitaires et d'integration couvrant domaine, sync, fixtures et migration.
|
||||
|
||||
## 13. Critères d'acceptation (DoD)
|
||||
@@ -553,4 +553,4 @@ Les tests d'integration migration up/down exigent une base de test dediee avec u
|
||||
- La suppression d'un role systeme leve `SystemRoleDeletionException` au niveau domaine.
|
||||
- Les associations `User::$roles`, `User::$directPermissions` et `Role::$permissions` sont explicitement configurees en `fetch=EAGER` et ce point est verifie par tests.
|
||||
- Les fixtures attribuent `is_admin = true` + role `admin` a l'utilisateur `admin`, et le role `user` aux utilisateurs standards.
|
||||
- Le spec est compatible avec l'architecture modulaire actuelle basee sur `/home/matthieu/dev_malio/Starseed/config/modules.php` et n'introduit aucune resource API Platform ni voter dans ce ticket.
|
||||
- Le spec est compatible avec l'architecture modulaire actuelle basee sur `/home/matthieu/dev_malio/Coltura/config/modules.php` et n'introduit aucune resource API Platform ni voter dans ce ticket.
|
||||
|
||||
@@ -38,28 +38,28 @@ Le ticket n'introduit **aucune logique d'autorisation metier** : toute la verifi
|
||||
|
||||
### Infrastructure - Processors
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php`
|
||||
Decorator de `ApiPlatform\Doctrine\Common\State\PersistProcessor` et `RemoveProcessor`. Charge de la garde `ensureDeletable()` et de la protection des champs immuables sur un role systeme.
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`
|
||||
Decorator de `PersistProcessor` specifique a l'operation `PATCH /api/users/{id}/rbac`. Persiste les mutations `isAdmin`, `roles`, `directPermissions` sans passer par `UserPasswordHasherProcessor`.
|
||||
|
||||
### Tests unitaires
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php`
|
||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php`
|
||||
|
||||
### Tests fonctionnels
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Api/PermissionApiTest.php`
|
||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Api/RoleApiTest.php`
|
||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Api/UserRbacApiTest.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/PermissionApiTest.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/RoleApiTest.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/UserRbacApiTest.php`
|
||||
|
||||
## 4. Fichiers a modifier
|
||||
|
||||
### Entite `Permission`
|
||||
|
||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/Permission.php`
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php`
|
||||
|
||||
- Ajouter l'attribut `#[ApiResource]` avec operations `GetCollection` + `Get` uniquement.
|
||||
- Normalization context : groupe `permission:read` uniquement.
|
||||
@@ -89,7 +89,7 @@ Extrait attendu :
|
||||
|
||||
### Entite `Role`
|
||||
|
||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/Role.php`
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php`
|
||||
|
||||
- Ajouter l'attribut `#[ApiResource]` avec operations `GetCollection`, `Get`, `Post`, `Patch`, `Delete`.
|
||||
- Normalization context : `role:read`. Denormalization context : `role:write`.
|
||||
@@ -107,7 +107,7 @@ Extrait attendu :
|
||||
|
||||
### Entite `User`
|
||||
|
||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/User.php`
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php`
|
||||
|
||||
- Ajouter dans la liste des operations `ApiResource` existantes une operation dediee :
|
||||
|
||||
|
||||
@@ -39,50 +39,50 @@ A l'issue de ce ticket, l'application dispose d'un systeme d'autorisation applic
|
||||
|
||||
### Domaine - Securite
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Security/AdminHeadcountGuard.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/AdminHeadcountGuard.php`
|
||||
Service domaine encapsulant l'invariant "au moins un admin reste apres l'operation". Depend uniquement de `UserRepositoryInterface::countAdmins()`. Aucune dependance infrastructure, testable en isolation.
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Exception/LastAdminProtectionException.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Exception/LastAdminProtectionException.php`
|
||||
Exception metier levee par le guard. Traduite en `BadRequestHttpException` (400) dans les processors.
|
||||
|
||||
### Infrastructure - Security
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Security/PermissionVoter.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Security/PermissionVoter.php`
|
||||
Voter Symfony etendant `Symfony\Component\Security\Core\Authorization\Voter\Voter`. Decouvert automatiquement par `autoconfigure: true`.
|
||||
|
||||
### Infrastructure - Processors
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessor.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessor.php`
|
||||
Decorateur de `RemoveProcessor` cible sur `DELETE /api/users/{id}`. Appelle `AdminHeadcountGuard` avant de deleguer. Meme pattern qu'`UserRbacProcessor`/`RoleProcessor` : `final class`, `#[Autowire]` sur l'inner, `LogicException` fail-fast si le type entrant n'est pas `User`.
|
||||
|
||||
### Frontend - Composable
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/frontend/shared/composables/usePermissions.ts`
|
||||
- `/home/matthieu/dev_malio/Coltura/frontend/shared/composables/usePermissions.ts`
|
||||
Composable stateless qui lit `useAuthStore().user`. Pas de fetch propre, pas de reset (le cycle de vie est porte par l'auth store).
|
||||
|
||||
### Tests unitaires PHP
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Infrastructure/Security/PermissionVoterTest.php`
|
||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php`
|
||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessorTest.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/Security/PermissionVoterTest.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php`
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessorTest.php`
|
||||
|
||||
### Tests fonctionnels PHP
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Api/MeApiTest.php` (si absent — sinon extension)
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/MeApiTest.php` (si absent — sinon extension)
|
||||
Couvre l'enrichissement du payload `/api/me`.
|
||||
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Api/UserApiTest.php` (si absent — sinon extension)
|
||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/UserApiTest.php` (si absent — sinon extension)
|
||||
Couvre la garde "dernier admin global" sur `DELETE /api/users/{id}`.
|
||||
|
||||
### Tests frontend
|
||||
|
||||
- `/home/matthieu/dev_malio/Starseed/frontend/shared/composables/__tests__/usePermissions.test.ts`
|
||||
- `/home/matthieu/dev_malio/Coltura/frontend/shared/composables/__tests__/usePermissions.test.ts`
|
||||
Vitest. Emplacement a adapter si le projet Nuxt a une autre convention (colocalise avec un fichier `.spec.ts`, ou repertoire `tests/`). A verifier au debut de la task frontend.
|
||||
|
||||
## 4. Fichiers a modifier
|
||||
|
||||
### `CoreModule.php`
|
||||
|
||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/CoreModule.php`
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php`
|
||||
|
||||
Ajouter une cinquieme entree au catalogue :
|
||||
|
||||
@@ -103,7 +103,7 @@ La commande `app:sync-permissions` creera automatiquement `core.roles.view` a la
|
||||
|
||||
### Entite `Permission`
|
||||
|
||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/Permission.php`
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php`
|
||||
|
||||
Remplacer les 2 gardes placeholder :
|
||||
|
||||
@@ -122,7 +122,7 @@ Supprimer les commentaires `// TODO ticket #345`.
|
||||
|
||||
### Entite `Role`
|
||||
|
||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/Role.php`
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php`
|
||||
|
||||
Remplacer les 5 gardes placeholder :
|
||||
|
||||
@@ -136,7 +136,7 @@ Supprimer les commentaires `// TODO ticket #345`.
|
||||
|
||||
### Entite `User`
|
||||
|
||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/User.php`
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php`
|
||||
|
||||
Remplacer les 6 gardes `ROLE_ADMIN` restantes :
|
||||
|
||||
@@ -172,7 +172,7 @@ Supprimer tous les commentaires `// TODO ticket #345` rencontres.
|
||||
|
||||
### `UserRepositoryInterface`
|
||||
|
||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Repository/UserRepositoryInterface.php`
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/UserRepositoryInterface.php`
|
||||
|
||||
Ajouter la methode :
|
||||
|
||||
@@ -187,7 +187,7 @@ public function countAdmins(): int;
|
||||
|
||||
### `DoctrineUserRepository`
|
||||
|
||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php`
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php`
|
||||
|
||||
Implementer `countAdmins()` via un `QueryBuilder` simple :
|
||||
|
||||
@@ -204,7 +204,7 @@ public function countAdmins(): int
|
||||
|
||||
### `UserRbacProcessor`
|
||||
|
||||
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`
|
||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`
|
||||
|
||||
Ajouter la dependance `AdminHeadcountGuard` et l'invoquer **apres** la garde auto-suicide existante, **avant** de deleguer au persist processor. Supprimer le `TODO ticket #345` du docblock.
|
||||
|
||||
|
||||
@@ -10,14 +10,14 @@ Le resultat attendu est un socle de persistance activable par tenant via `config
|
||||
|
||||
### IN
|
||||
|
||||
- Creer le module `/home/m-tristan/workspace/Starseed/src/Module/Sites/SitesModule.php` avec `ID = 'sites'`, `LABEL = 'Sites'`, `REQUIRED = false`, et une methode statique `permissions()` declarant les deux codes RBAC `sites.view` et `sites.manage`.
|
||||
- Creer le module `/home/m-tristan/workspace/Coltura/src/Module/Sites/SitesModule.php` avec `ID = 'sites'`, `LABEL = 'Sites'`, `REQUIRED = false`, et une methode statique `permissions()` declarant les deux codes RBAC `sites.view` et `sites.manage`.
|
||||
- Creer l'entite Doctrine `Site` avec `id`, `name` (unique), `city`, `postalCode`, `color`, `fullAddress`, `createdAt`, `updatedAt` et les contraintes de validation applicatives associees (NotBlank, Length, Regex hex `#RRGGBB`, Regex CP FR `^\d{5}$`, UniqueEntity).
|
||||
- Creer l'interface `SiteRepositoryInterface` et son implementation Doctrine `DoctrineSiteRepository`, avec un contrat CRUD complet (`findById`, `findByName`, `findAllOrderedByName`, `save`, `remove`) en anticipation du ticket 2.
|
||||
- Creer une migration Doctrine creant la table `site` avec son index unique `uniq_site_name`. La migration est placee dans `/home/m-tristan/workspace/Starseed/migrations/` au namespace racine `DoctrineMigrations` conformement a l'exception documentee dans `CLAUDE.md` (bug de tri alphabetique des migrations multi-namespaces dans Doctrine Migrations 3.x).
|
||||
- Creer une migration Doctrine creant la table `site` avec son index unique `uniq_site_name`. La migration est placee dans `/home/m-tristan/workspace/Coltura/migrations/` au namespace racine `DoctrineMigrations` conformement a l'exception documentee dans `CLAUDE.md` (bug de tri alphabetique des migrations multi-namespaces dans Doctrine Migrations 3.x).
|
||||
- Creer `SitesFixtures` creant trois sites de demonstration : `Chatellerault` (`#056CF2`), `Saint-Jean` (`#10B981`), `Pommevic` (`#F59E0B`). Fixtures idempotentes via lookup par nom lorsque le purger Doctrine est desactive.
|
||||
- Enregistrer `SitesModule::class` dans `/home/m-tristan/workspace/Starseed/config/modules.php` pour l'activer par defaut.
|
||||
- Declarer le mapping Doctrine du module dans `/home/m-tristan/workspace/Starseed/config/packages/doctrine.yaml` (inconditionnel, le mapping reste charge meme si le module est retire de `modules.php`).
|
||||
- Enregistrer l'alias service `SiteRepositoryInterface → DoctrineSiteRepository` dans `/home/m-tristan/workspace/Starseed/config/services.yaml`.
|
||||
- Enregistrer `SitesModule::class` dans `/home/m-tristan/workspace/Coltura/config/modules.php` pour l'activer par defaut.
|
||||
- Declarer le mapping Doctrine du module dans `/home/m-tristan/workspace/Coltura/config/packages/doctrine.yaml` (inconditionnel, le mapping reste charge meme si le module est retire de `modules.php`).
|
||||
- Enregistrer l'alias service `SiteRepositoryInterface → DoctrineSiteRepository` dans `/home/m-tristan/workspace/Coltura/config/services.yaml`.
|
||||
- Ajouter deux suites de tests PHPUnit :
|
||||
- `SiteTest` (pure `TestCase`) pour le comportement de l'entite (constructeur, getters/setters, lifecycle `PreUpdate`).
|
||||
- `SiteValidationTest` (`KernelTestCase`) pour la validation complete : regex hex, regex CP FR, NotBlank, Length, UniqueEntity via Doctrine.
|
||||
@@ -25,7 +25,7 @@ Le resultat attendu est un socle de persistance activable par tenant via `config
|
||||
### OUT
|
||||
|
||||
- Ticket `#02` : relation `User ↔ Site` (FK ou ManyToMany selon decision UX), expose les sites de l'utilisateur courant via `/api/me` et propage l'autorisation au niveau des ressources decoupees par site.
|
||||
- Ticket `#03` : integration dans la navbar Starseed (selecteur de site actif, persistance du choix cote front, consommation du flux issu du ticket 2).
|
||||
- Ticket `#03` : integration dans la navbar Coltura (selecteur de site actif, persistance du choix cote front, consommation du flux issu du ticket 2).
|
||||
- Ticket `#04` : ecran d'administration CRUD des sites (page admin/sites, DataTable, drawer creation/edition, modale suppression, API Platform `Site` resource avec voters RBAC).
|
||||
- Gestion des soft-deletes sur `Site` : non introduite dans ce ticket.
|
||||
- Rattachement historique ou audit trail des modifications : hors scope.
|
||||
@@ -34,38 +34,38 @@ Le resultat attendu est un socle de persistance activable par tenant via `config
|
||||
|
||||
### Domaine — Entité
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Domain/Entity/Site.php` : entite Doctrine porteuse des attributs metier (nom unique, ville, code postal FR, couleur hex, adresse complete multi-ligne) et des timestamps auto-maintenus via lifecycle callbacks.
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Domain/Entity/Site.php` : entite Doctrine porteuse des attributs metier (nom unique, ville, code postal FR, couleur hex, adresse complete multi-ligne) et des timestamps auto-maintenus via lifecycle callbacks.
|
||||
|
||||
### Domaine — Repository
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Domain/Repository/SiteRepositoryInterface.php` : contrat d'acces domaine a l'entite Site (CRUD applicatif ; l'acces API Platform du ticket 4 utilisera le provider Doctrine par defaut).
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Domain/Repository/SiteRepositoryInterface.php` : contrat d'acces domaine a l'entite Site (CRUD applicatif ; l'acces API Platform du ticket 4 utilisera le provider Doctrine par defaut).
|
||||
|
||||
### Infrastructure — Doctrine
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php` : implementation Doctrine de `SiteRepositoryInterface` basee sur `ServiceEntityRepository`.
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php` : implementation Doctrine de `SiteRepositoryInterface` basee sur `ServiceEntityRepository`.
|
||||
|
||||
### Infrastructure — Migration
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/migrations/Version<timestamp>.php` : migration racine (namespace `DoctrineMigrations`) qui cree la table `site` et son index unique. Emplacement racine et non modulaire, cf. exception documentee dans `CLAUDE.md` (bug Doctrine 3.x sur le tri alphabetique des migrations multi-namespaces).
|
||||
- `/home/m-tristan/workspace/Coltura/migrations/Version<timestamp>.php` : migration racine (namespace `DoctrineMigrations`) qui cree la table `site` et son index unique. Emplacement racine et non modulaire, cf. exception documentee dans `CLAUDE.md` (bug Doctrine 3.x sur le tri alphabetique des migrations multi-namespaces).
|
||||
|
||||
### Infrastructure — DataFixtures
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php` : fixture Doctrine seedant les 3 sites de demonstration. Ne declare pas de `DependentFixtureInterface` (aucune dependance a AppFixtures dans ce ticket).
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php` : fixture Doctrine seedant les 3 sites de demonstration. Ne declare pas de `DependentFixtureInterface` (aucune dependance a AppFixtures dans ce ticket).
|
||||
|
||||
### Module — Declaration
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/SitesModule.php` : marker class du module avec `ID`, `LABEL`, `REQUIRED` et `permissions()`. Meme pattern que `CoreModule`.
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/SitesModule.php` : marker class du module avec `ID`, `LABEL`, `REQUIRED` et `permissions()`. Meme pattern que `CoreModule`.
|
||||
|
||||
### Tests
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Domain/Entity/SiteTest.php` : tests unitaires purs (`TestCase`) couvrant constructeur, getters, setters et lifecycle `PreUpdate`.
|
||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Domain/Entity/SiteValidationTest.php` : tests de validation (`KernelTestCase`) couvrant regex hex, regex CP FR, NotBlank, Length sur tous les champs, et `UniqueEntity` via la DB de test.
|
||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Domain/Entity/SiteTest.php` : tests unitaires purs (`TestCase`) couvrant constructeur, getters, setters et lifecycle `PreUpdate`.
|
||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Domain/Entity/SiteValidationTest.php` : tests de validation (`KernelTestCase`) couvrant regex hex, regex CP FR, NotBlank, Length sur tous les champs, et `UniqueEntity` via la DB de test.
|
||||
|
||||
## 4. Fichiers à modifier
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/config/modules.php` : ajouter `App\Module\Sites\SitesModule::class` dans le tableau de retour. Le module est actif par defaut. Le commenter suffit a le desactiver sans autre intervention (les permissions deviendront orphelines a la prochaine sync mais la table reste).
|
||||
- `/home/m-tristan/workspace/Starseed/config/packages/doctrine.yaml` : ajouter une mapping `Sites:` alignee sur le pattern du module `Core:`. Le mapping est inconditionnel : il reste declare meme si `SitesModule::class` est retire de `modules.php`. Le commentaire doit etre explicite sur cette decoupe (activation fonctionnelle via `modules.php`, structure DB via la mapping Doctrine).
|
||||
- `/home/m-tristan/workspace/Starseed/config/services.yaml` : ajouter l'alias `App\Module\Sites\Domain\Repository\SiteRepositoryInterface` → `App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository`. Pattern aligne sur les trois aliases Core existants.
|
||||
- `/home/m-tristan/workspace/Coltura/config/modules.php` : ajouter `App\Module\Sites\SitesModule::class` dans le tableau de retour. Le module est actif par defaut. Le commenter suffit a le desactiver sans autre intervention (les permissions deviendront orphelines a la prochaine sync mais la table reste).
|
||||
- `/home/m-tristan/workspace/Coltura/config/packages/doctrine.yaml` : ajouter une mapping `Sites:` alignee sur le pattern du module `Core:`. Le mapping est inconditionnel : il reste declare meme si `SitesModule::class` est retire de `modules.php`. Le commentaire doit etre explicite sur cette decoupe (activation fonctionnelle via `modules.php`, structure DB via la mapping Doctrine).
|
||||
- `/home/m-tristan/workspace/Coltura/config/services.yaml` : ajouter l'alias `App\Module\Sites\Domain\Repository\SiteRepositoryInterface` → `App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository`. Pattern aligne sur les trois aliases Core existants.
|
||||
|
||||
## 5. Schéma cible — mapping Doctrine
|
||||
|
||||
@@ -152,7 +152,7 @@ Sites:
|
||||
|
||||
## 6. Plan de migration Doctrine
|
||||
|
||||
La migration est placee dans `/home/m-tristan/workspace/Starseed/migrations/Version<timestamp>.php` au namespace racine `DoctrineMigrations`, conformement a l'exception documentee dans `CLAUDE.md`. Tant que le bug de tri alphabetique des `MigrationsComparator` multi-namespaces n'est pas resolu (via un comparator custom ou un upgrade Doctrine), toute migration d'initialisation (creation de table sur base vide) reste au namespace racine.
|
||||
La migration est placee dans `/home/m-tristan/workspace/Coltura/migrations/Version<timestamp>.php` au namespace racine `DoctrineMigrations`, conformement a l'exception documentee dans `CLAUDE.md`. Tant que le bug de tri alphabetique des `MigrationsComparator` multi-namespaces n'est pas resolu (via un comparator custom ou un upgrade Doctrine), toute migration d'initialisation (creation de table sur base vide) reste au namespace racine.
|
||||
|
||||
### `up()` — ordre des instructions
|
||||
|
||||
@@ -289,7 +289,7 @@ Trois sites de demonstration, avec des couleurs distinctes suffisamment contrast
|
||||
|
||||
| Nom | Ville | CP | Couleur | Commentaire |
|
||||
|-----|-------|-----|---------|-------------|
|
||||
| Chatellerault | Chatellerault | 86100 | `#056CF2` | Couleur imposee par le ticket (bleu Starseed). |
|
||||
| Chatellerault | Chatellerault | 86100 | `#056CF2` | Couleur imposee par le ticket (bleu Coltura). |
|
||||
| Saint-Jean | Saint-Jean-de-Sauves | 86330 | `#10B981` | Vert emeraude (contraste avec le bleu). |
|
||||
| Pommevic | Pommevic | 82400 | `#F59E0B` | Ambre (troisieme teinte nettement distincte). |
|
||||
|
||||
|
||||
@@ -40,70 +40,70 @@ Le resultat attendu est un module Sites utilisable de bout en bout cote admin (c
|
||||
|
||||
### Backend — Module Sites
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Domain/Exception/SiteNotAuthorizedException.php` : exception domaine levee si un user tente de switcher vers un site qui ne fait pas partie de ses sites autorises. Porte un message i18n-able et le code du site cible.
|
||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/ApiPlatform/Resource/CurrentSiteResource.php` : ressource API Platform **virtuelle** (pas de mapping Doctrine, pas de `#[ORM\Entity]`). Sert uniquement a porter l'operation `Patch` `/me/current-site`. Expose une propriete `site: Site` en denormalisation pour recevoir l'IRI du site cible, et re-expose l'user courant en normalisation via le groupe `me:read`.
|
||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/CurrentSiteProcessor.php` : processor dedie a l'operation de switch. Valide l'appartenance du site aux `user.sites`, positionne `user.currentSite`, flush, retourne l'user.
|
||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/ApiPlatform/EventListener/SiteNotAuthorizedExceptionListener.php` : listener Kernel qui convertit `SiteNotAuthorizedException` en `ForbiddenHttpException` (403) avec un code i18n stable (cf. pattern `SystemRoleDeletionException` du module Core dans les tickets RBAC precedents).
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Domain/Exception/SiteNotAuthorizedException.php` : exception domaine levee si un user tente de switcher vers un site qui ne fait pas partie de ses sites autorises. Porte un message i18n-able et le code du site cible.
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/Resource/CurrentSiteResource.php` : ressource API Platform **virtuelle** (pas de mapping Doctrine, pas de `#[ORM\Entity]`). Sert uniquement a porter l'operation `Patch` `/me/current-site`. Expose une propriete `site: Site` en denormalisation pour recevoir l'IRI du site cible, et re-expose l'user courant en normalisation via le groupe `me:read`.
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/CurrentSiteProcessor.php` : processor dedie a l'operation de switch. Valide l'appartenance du site aux `user.sites`, positionne `user.currentSite`, flush, retourne l'user.
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/EventListener/SiteNotAuthorizedExceptionListener.php` : listener Kernel qui convertit `SiteNotAuthorizedException` en `ForbiddenHttpException` (403) avec un code i18n stable (cf. pattern `SystemRoleDeletionException` du module Core dans les tickets RBAC precedents).
|
||||
|
||||
### Backend — Migration
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/migrations/Version<timestamp2>.php` : migration au namespace racine `DoctrineMigrations` (cf. exception Doctrine documentee dans `CLAUDE.md`). Cree la table `user_site` et la colonne `user.current_site_id` avec les FKs et cascades appropriees.
|
||||
- `/home/m-tristan/workspace/Coltura/migrations/Version<timestamp2>.php` : migration au namespace racine `DoctrineMigrations` (cf. exception Doctrine documentee dans `CLAUDE.md`). Cree la table `user_site` et la colonne `user.current_site_id` avec les FKs et cascades appropriees.
|
||||
|
||||
### Backend — Tests API
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Api/SiteApiTest.php` : CRUD complet `/api/sites` avec matrices RBAC (admin, user avec `sites.view`, user avec `sites.manage`, user sans permission).
|
||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Api/CurrentSiteSwitchApiTest.php` : PATCH `/me/current-site` (OK avec site autorise, 403 avec site non autorise, 400 avec IRI invalide).
|
||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Api/MeEndpointSitesTest.php` : `/api/me` expose bien `sites` et `currentSite` en objets. User sans site : `sites: []`, `currentSite: null`.
|
||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Api/SiteCascadeTest.php` : suppression d'un site `X` → toutes les lignes `user_site` referencant `X` sont supprimees, tous les users ayant `X` en `currentSite` voient leur `currentSite` repasser a `NULL`.
|
||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Core/Api/UserRbacSitesApiTest.php` : extension du endpoint `/api/users/{id}/rbac` — ajout de `sites: []` dans le payload, retrait du `currentSite` quand le site retire etait le courant.
|
||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Api/SiteApiTest.php` : CRUD complet `/api/sites` avec matrices RBAC (admin, user avec `sites.view`, user avec `sites.manage`, user sans permission).
|
||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Api/CurrentSiteSwitchApiTest.php` : PATCH `/me/current-site` (OK avec site autorise, 403 avec site non autorise, 400 avec IRI invalide).
|
||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Api/MeEndpointSitesTest.php` : `/api/me` expose bien `sites` et `currentSite` en objets. User sans site : `sites: []`, `currentSite: null`.
|
||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Api/SiteCascadeTest.php` : suppression d'un site `X` → toutes les lignes `user_site` referencant `X` sont supprimees, tous les users ayant `X` en `currentSite` voient leur `currentSite` repasser a `NULL`.
|
||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Core/Api/UserRbacSitesApiTest.php` : extension du endpoint `/api/users/{id}/rbac` — ajout de `sites: []` dans le payload, retrait du `currentSite` quand le site retire etait le courant.
|
||||
|
||||
### Frontend — Module Sites (nouveau layer)
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/nuxt.config.ts` : marker de layer Nuxt (vide). Declenche l'auto-detection par `nuxt.config.ts` racine.
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/pages/admin/sites.vue` : page `/admin/sites`. Reutilise les composants Malio UI (`MalioDataTable`, `MalioButton`, `MalioInputText`, `MalioInputTextArea`). Pattern identique a `frontend/modules/core/pages/admin/roles.vue`.
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/components/SiteDrawer.vue` : drawer creation/edition. Formulaire 5 champs (nom, ville, CP, couleur avec preview puce, adresse). Valide cote front sur le submit avant d'envoyer.
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/components/SiteDeleteModal.vue` : modale de confirmation suppression. Pattern aligne sur `RoleDeleteModal.vue`.
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/nuxt.config.ts` : marker de layer Nuxt (vide). Declenche l'auto-detection par `nuxt.config.ts` racine.
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/pages/admin/sites.vue` : page `/admin/sites`. Reutilise les composants Malio UI (`MalioDataTable`, `MalioButton`, `MalioInputText`, `MalioInputTextArea`). Pattern identique a `frontend/modules/core/pages/admin/roles.vue`.
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/components/SiteDrawer.vue` : drawer creation/edition. Formulaire 5 champs (nom, ville, CP, couleur avec preview puce, adresse). Valide cote front sur le submit avant d'envoyer.
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/components/SiteDeleteModal.vue` : modale de confirmation suppression. Pattern aligne sur `RoleDeleteModal.vue`.
|
||||
|
||||
### Frontend — Types partages
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/shared/types/sites.ts` : types `Site`, `SiteInput`. Pattern identique a `frontend/shared/types/rbac.ts`.
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/shared/types/sites.ts` : types `Site`, `SiteInput`. Pattern identique a `frontend/shared/types/rbac.ts`.
|
||||
|
||||
### Tests frontend (optionnels mais recommandes)
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/pages/admin/sites.spec.ts` : smoke test Vitest (rendu + clic bouton "Nouveau site" ouvre le drawer).
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/pages/admin/sites.spec.ts` : smoke test Vitest (rendu + clic bouton "Nouveau site" ouvre le drawer).
|
||||
|
||||
## 4. Fichiers à modifier
|
||||
|
||||
### Backend — Module Core
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/src/Module/Core/Domain/Entity/User.php` :
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Core/Domain/Entity/User.php` :
|
||||
- Ajouter `private Collection $sites;` (M2M, `fetch: EAGER`, `JoinTable: user_site`), groupes `me:read`, `user:list`, `user:rbac:read`, `user:rbac:write`.
|
||||
- Ajouter `private ?Site $currentSite = null;` (M2O, `fetch: EAGER`, `onDelete: 'SET NULL'`), groupe `me:read`.
|
||||
- Initialiser `$this->sites = new ArrayCollection();` dans le constructeur.
|
||||
- Ajouter les accesseurs `getSites()`, `addSite(Site)`, `removeSite(Site)`, `hasSite(Site)`, `getCurrentSite()`, `setCurrentSite(?Site)`.
|
||||
- **Important** : `import` direct `App\Module\Sites\Domain\Entity\Site`. Ce ticket assume le couplage Core → Sites au niveau code PHP (cf. Risque 1).
|
||||
- `/home/m-tristan/workspace/Starseed/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php` :
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php` :
|
||||
- Etendre le contrat d'entree pour accepter le champ `sites` (collection d'IRIs denormalisees en `Collection<Site>`).
|
||||
- Apres l'application des roles et permissions directes, detecter si `currentSite` du user cible n'est plus dans la nouvelle collection `sites` → basculer `currentSite` a `null`.
|
||||
- Conserver toutes les gardes existantes (auto-suicide admin, dernier admin global).
|
||||
- `/home/m-tristan/workspace/Starseed/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` :
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` :
|
||||
- Declarer l'implementation `DependentFixtureInterface` avec `getDependencies(): [SitesFixtures::class]` (inversion de l'ordre actuel : AppFixtures doit tourner **apres** SitesFixtures pour pouvoir reference les sites).
|
||||
- Rattacher chaque user a au moins un site : `admin` a tous les sites (`Chatellerault`, `Saint-Jean`, `Pommevic`), `alice` a `Chatellerault`, `bob` a `Saint-Jean`.
|
||||
- Positionner `currentSite` : `admin.currentSite = Chatellerault`, `alice.currentSite = Chatellerault`, `bob.currentSite = Saint-Jean`.
|
||||
|
||||
### Backend — Module Sites
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Domain/Entity/Site.php` :
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Domain/Entity/Site.php` :
|
||||
- Ajouter les attributs `#[ApiResource]` + operations (cf. section 5 Schema).
|
||||
- Ajouter les groupes de serialisation `site:read`, `site:write`, `me:read` sur les proprietes scalaires.
|
||||
- Ajouter la relation inverse `private Collection $users;` (M2M mappedBy=`sites`), **sans** groupe de serialisation (pas d'exposition API cote Site).
|
||||
- Initialiser `$this->users = new ArrayCollection();` dans le constructeur.
|
||||
- Ajouter les accesseurs `getUsers()` pour les besoins metier (count / cascade manuel si besoin).
|
||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php` : aucun changement de contenu, mais verifier que la fixture n'est plus en bout de chaine de dependance (AppFixtures depend d'elle maintenant).
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php` : aucun changement de contenu, mais verifier que la fixture n'est plus en bout de chaine de dependance (AppFixtures depend d'elle maintenant).
|
||||
|
||||
### Backend — Configuration
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/config/sidebar.php` : inserer l'entree `Sites` dans la section `sidebar.general.section` entre `sidebar.core.users` et `sidebar.general.logout` :
|
||||
- `/home/m-tristan/workspace/Coltura/config/sidebar.php` : inserer l'entree `Sites` dans la section `sidebar.general.section` entre `sidebar.core.users` et `sidebar.general.logout` :
|
||||
```php
|
||||
[
|
||||
'label' => 'sidebar.core.sites',
|
||||
@@ -113,18 +113,18 @@ Le resultat attendu est un module Sites utilisable de bout en bout cote admin (c
|
||||
'permission' => 'sites.view',
|
||||
],
|
||||
```
|
||||
- `/home/m-tristan/workspace/Starseed/config/services.yaml` : aucun changement requis. `CurrentSiteProcessor`, `SiteNotAuthorizedExceptionListener` sont autoconfigures.
|
||||
- `/home/m-tristan/workspace/Coltura/config/services.yaml` : aucun changement requis. `CurrentSiteProcessor`, `SiteNotAuthorizedExceptionListener` sont autoconfigures.
|
||||
|
||||
### Frontend
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/core/components/UserRbacDrawer.vue` :
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/core/components/UserRbacDrawer.vue` :
|
||||
- Charger `GET /api/sites?itemsPerPage=999` a l'ouverture du drawer (parallelement aux roles et permissions deja charges).
|
||||
- Ajouter une section `sidebar.admin.usersDrawer.sitesSection` sous la section permissions directes, avec un groupe de `MalioCheckbox` par site (ou un `MalioMultiSelect` si le composant existe dans `@malio/layer-ui`).
|
||||
- Etendre le payload `PATCH /api/users/{id}/rbac` avec `sites: Array<string>` (IRIs).
|
||||
- Auto-refresh de l'auth store apres save si `isSelfEdit` (deja present, conserver).
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/shared/types/rbac.ts` : ajouter le champ `sites: string[]` a `UserListItem` (IRIs de sites attaches).
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/shared/stores/auth.ts` : le store auth expose deja `user` via `/api/me`. Aucune modification requise, les nouveaux champs `sites` et `currentSite` suivent automatiquement via la typologie — a condition de mettre a jour le type `UserData` dans `shared/types/` (ajouter `sites: Site[]` et `currentSite: Site | null`).
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/i18n/locales/fr.json` : cles
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/shared/types/rbac.ts` : ajouter le champ `sites: string[]` a `UserListItem` (IRIs de sites attaches).
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/shared/stores/auth.ts` : le store auth expose deja `user` via `/api/me`. Aucune modification requise, les nouveaux champs `sites` et `currentSite` suivent automatiquement via la typologie — a condition de mettre a jour le type `UserData` dans `shared/types/` (ajouter `sites: Site[]` et `currentSite: Site | null`).
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/i18n/locales/fr.json` : cles
|
||||
- `sidebar.core.sites` = "Sites".
|
||||
- `admin.sites.title`, `admin.sites.newSite`, `admin.sites.editSite`, `admin.sites.createSite`, `admin.sites.noSites`.
|
||||
- `admin.sites.table.{name, city, postalCode, color, fullAddress}`.
|
||||
@@ -228,7 +228,7 @@ final class CurrentSiteResource
|
||||
|
||||
## 6. Plan de migration Doctrine
|
||||
|
||||
La migration est placee dans `/home/m-tristan/workspace/Starseed/migrations/Version<timestamp2>.php` au namespace racine (cf. Risque 2 du ticket 1 et `CLAUDE.md`).
|
||||
La migration est placee dans `/home/m-tristan/workspace/Coltura/migrations/Version<timestamp2>.php` au namespace racine (cf. Risque 2 du ticket 1 et `CLAUDE.md`).
|
||||
|
||||
### `up()` — ordre des instructions
|
||||
|
||||
|
||||
@@ -77,42 +77,42 @@ Resultat attendu : apres merge, un user avec ≥ 1 site voit une barre sous la n
|
||||
|
||||
### Frontend — Module Sites (layer deja cree au ticket 2)
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/components/SiteSelector.vue` : wrapper Vue autour de `MalioSiteSelector`. Branche `useCurrentSite()`, gere l'optimistic update et les toasts.
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/composables/useCurrentSite.ts` : composable global exposant l'etat `currentSite` / `availableSites`, les actions `switchSite`, `resetCurrentSite`, et un flag `switching: Ref<boolean>` pour desactiver le selecteur pendant une requete en vol.
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/components/SiteSelector.vue` : wrapper Vue autour de `MalioSiteSelector`. Branche `useCurrentSite()`, gere l'optimistic update et les toasts.
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/composables/useCurrentSite.ts` : composable global exposant l'etat `currentSite` / `availableSites`, les actions `switchSite`, `resetCurrentSite`, et un flag `switching: Ref<boolean>` pour desactiver le selecteur pendant une requete en vol.
|
||||
|
||||
### Frontend — Shared
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/shared/composables/useModules.ts` : composable qui charge `/api/modules` et expose `isModuleActive(id: string): boolean`. Pattern aligne sur `useSidebar()` : ref singleton au niveau module, chargement idempotent, `resetModules()` expose pour le logout.
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/shared/utils/color.ts` : fonctions utilitaires de couleur, au minimum :
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/shared/composables/useModules.ts` : composable qui charge `/api/modules` et expose `isModuleActive(id: string): boolean`. Pattern aligne sur `useSidebar()` : ref singleton au niveau module, chargement idempotent, `resetModules()` expose pour le logout.
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/shared/utils/color.ts` : fonctions utilitaires de couleur, au minimum :
|
||||
- `parseHex(hex: string): { r: number; g: number; b: number }` — tolere la casse, rejette les formats hors `#RRGGBB`.
|
||||
- `getRelativeLuminance({r, g, b}): number` — formule WCAG standard.
|
||||
- `getReadableTextColor(hex: string): 'black' | 'white'` — renvoie `'black'` si la luminance > 0.5, `'white'` sinon. Seuil simple, suffisant pour un CRM interne (pas WCAG AAA).
|
||||
|
||||
### Frontend — Tests
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/composables/__tests__/useCurrentSite.spec.ts` : Vitest. Tests :
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/composables/__tests__/useCurrentSite.spec.ts` : Vitest. Tests :
|
||||
- `switchSite` met a jour l'etat localement avant la requete (optimistic).
|
||||
- Si la requete reussit, l'etat reste aligne.
|
||||
- Si la requete echoue, l'etat rollback a l'ancien `currentSite`.
|
||||
- `resetCurrentSite` vide l'etat.
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/shared/composables/__tests__/useModules.spec.ts` : Vitest. Tests `isModuleActive` apres chargement, `resetModules` vide l'etat.
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/shared/utils/__tests__/color.spec.ts` : Vitest. Jeu de donnees sur `getReadableTextColor` : `#000000` → white, `#FFFFFF` → black, `#056CF2` (bleu Starseed) → white, `#F59E0B` (ambre) → black, `#10B981` (vert) → black ou white selon seuil (a verifier). Tester aussi le rejet de formats invalides.
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts` : smoke test Vitest.
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/shared/composables/__tests__/useModules.spec.ts` : Vitest. Tests `isModuleActive` apres chargement, `resetModules` vide l'etat.
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/shared/utils/__tests__/color.spec.ts` : Vitest. Jeu de donnees sur `getReadableTextColor` : `#000000` → white, `#FFFFFF` → black, `#056CF2` (bleu Coltura) → white, `#F59E0B` (ambre) → black, `#10B981` (vert) → black ou white selon seuil (a verifier). Tester aussi le rejet de formats invalides.
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts` : smoke test Vitest.
|
||||
|
||||
## 4. Fichiers à modifier
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/package.json` : upgrade `@malio/layer-ui` vers la version qui inclut `MalioSiteSelector`. Commit du `package-lock.json` dans le meme changeset.
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/shared/types/user-data.ts` : ajouter les champs
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/package.json` : upgrade `@malio/layer-ui` vers la version qui inclut `MalioSiteSelector`. Commit du `package-lock.json` dans le meme changeset.
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/shared/types/user-data.ts` : ajouter les champs
|
||||
```ts
|
||||
sites: Site[]
|
||||
currentSite: Site | null
|
||||
```
|
||||
Import du type `Site` depuis `./sites`. Note : si le type `Site` a deja ete introduit au ticket 2, reutiliser ; sinon, ce ticket le cree dans `frontend/shared/types/sites.ts`.
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/shared/types/sites.ts` : si absent, creer avec l'interface `Site` (cf. section Schema ticket 2 pour la forme). Si present, aucune modification.
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/app/layouts/default.vue` : integrer `SiteSelector` sous le header, avant `<main>`, dans le flex column. Rendu conditionnel via `v-if="showSiteSelector"` ou via un `defineAsyncComponent` chargement lazy si on veut eviter l'import statique quand le module est off.
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/app/middleware/auth.global.ts` : ajouter le chargement de `useModules().loadModules()` apres `loadSidebar()`. Necessaire pour que `isModuleActive` soit resolu quand le layout se rend.
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/modules/core/pages/logout.vue` : appeler `useCurrentSite().resetCurrentSite()` et `useModules().resetModules()` apres le `auth.logout()`, aligne sur le pattern `resetSidebar()` deja present.
|
||||
- `/home/m-tristan/workspace/Starseed/frontend/i18n/locales/fr.json` : ajouter les cles
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/shared/types/sites.ts` : si absent, creer avec l'interface `Site` (cf. section Schema ticket 2 pour la forme). Si present, aucune modification.
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/app/layouts/default.vue` : integrer `SiteSelector` sous le header, avant `<main>`, dans le flex column. Rendu conditionnel via `v-if="showSiteSelector"` ou via un `defineAsyncComponent` chargement lazy si on veut eviter l'import statique quand le module est off.
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/app/middleware/auth.global.ts` : ajouter le chargement de `useModules().loadModules()` apres `loadSidebar()`. Necessaire pour que `isModuleActive` soit resolu quand le layout se rend.
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/core/pages/logout.vue` : appeler `useCurrentSite().resetCurrentSite()` et `useModules().resetModules()` apres le `auth.logout()`, aligne sur le pattern `resetSidebar()` deja present.
|
||||
- `/home/m-tristan/workspace/Coltura/frontend/i18n/locales/fr.json` : ajouter les cles
|
||||
```json
|
||||
"sites": {
|
||||
"selector": {
|
||||
|
||||
@@ -34,50 +34,50 @@ Le ticket livre aussi une documentation developpeur (`docs/modules/site-aware.md
|
||||
|
||||
### Shared — Contrat
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/src/Shared/Domain/Contract/SiteAwareInterface.php` : interface minimale. Depends uniquement du type `App\Module\Sites\Domain\Entity\Site`, qui est deja couple cote Core depuis le ticket 2 — le placement dans Shared n'introduit pas de nouvelle dependance transversale non souhaitee.
|
||||
- `/home/m-tristan/workspace/Coltura/src/Shared/Domain/Contract/SiteAwareInterface.php` : interface minimale. Depends uniquement du type `App\Module\Sites\Domain\Entity\Site`, qui est deja couple cote Core depuis le ticket 2 — le placement dans Shared n'introduit pas de nouvelle dependance transversale non souhaitee.
|
||||
|
||||
### Module Sites — Application
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Application/Service/CurrentSiteProvider.php` : service injecte partout ou le site courant doit etre lu (extensions, processor, futurs voters). Gere les trois cas de retour `null` : pas d'user, `currentSite` null, module desactive.
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Application/Service/CurrentSiteProvider.php` : service injecte partout ou le site courant doit etre lu (extensions, processor, futurs voters). Gere les trois cas de retour `null` : pas d'user, `currentSite` null, module desactive.
|
||||
|
||||
### Module Sites — Infrastructure
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteScopedQueryExtension.php` : une seule classe, implementant a la fois `QueryCollectionExtensionInterface` et `QueryItemExtensionInterface`. Le comportement est identique pour les deux, modulo que l'item manque retourne 404 (API Platform converti un `getOneOrNullResult` null en 404).
|
||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessor.php` : decorator sur le persist processor Doctrine. Injecte le site courant sur `$data` si applicable, puis delegue a `$persistProcessor`.
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteScopedQueryExtension.php` : une seule classe, implementant a la fois `QueryCollectionExtensionInterface` et `QueryItemExtensionInterface`. Le comportement est identique pour les deux, modulo que l'item manque retourne 404 (API Platform converti un `getOneOrNullResult` null en 404).
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessor.php` : decorator sur le persist processor Doctrine. Injecte le site courant sur `$data` si applicable, puis delegue a `$persistProcessor`.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/docs/modules/site-aware.md` : guide developpeur (cf. contenu section 10).
|
||||
- `/home/m-tristan/workspace/Coltura/docs/modules/site-aware.md` : guide developpeur (cf. contenu section 10).
|
||||
|
||||
### Tests
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteScopedQueryExtensionTest.php` : tests d'integration (`KernelTestCase`) avec l'entite `FakeSiteAwareEntity` (declaree uniquement dans le dossier de tests). Verifie :
|
||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteScopedQueryExtensionTest.php` : tests d'integration (`KernelTestCase`) avec l'entite `FakeSiteAwareEntity` (declaree uniquement dans le dossier de tests). Verifie :
|
||||
- Le filtre s'applique sur une resource `SiteAware` quand le provider retourne un site.
|
||||
- Le filtre est no-op si `SiteAware` mais provider null.
|
||||
- Le filtre est no-op si resource non `SiteAware`.
|
||||
- Le filtre est no-op si user a `sites.bypass_scope`.
|
||||
- `totalItems` Hydra reflete bien le filtrage.
|
||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessorTest.php` : tests unitaires (`TestCase` pur) avec mocks. Verifie :
|
||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessorTest.php` : tests unitaires (`TestCase` pur) avec mocks. Verifie :
|
||||
- `$data` SiteAware sans site → injection du site courant.
|
||||
- `$data` SiteAware avec site deja positionne → pas d'overwrite.
|
||||
- `$data` non-SiteAware → delegation directe sans modification.
|
||||
- Provider retourne null (module off ou user sans site) ET `$data` SiteAware sans site → BadRequestHttpException (400) "aucun site selectionne".
|
||||
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Application/Service/CurrentSiteProviderTest.php` : tests unitaires `TestCase`. Couvre :
|
||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Application/Service/CurrentSiteProviderTest.php` : tests unitaires `TestCase`. Couvre :
|
||||
- User authentifie avec currentSite → retourne le Site.
|
||||
- User authentifie sans currentSite → null.
|
||||
- Pas d'user → null.
|
||||
- Module desactive dans config/modules.php de test → null meme si user.currentSite existe.
|
||||
- `/home/m-tristan/workspace/Starseed/tests/Fixtures/SiteAware/FakeSiteAwareEntity.php` : entite Doctrine minimale (`id`, `name`, `site`) utilisee **uniquement** en tests. Mapping Doctrine declare via un `#[ORM\Entity]` mais la table n'existe jamais en prod car la fixture n'est jamais chargee hors tests. **Alternative** : utiliser un schema DB dedie au dossier de tests, cree a la volee par un helper setUp. A trancher a l'implementation.
|
||||
- `/home/m-tristan/workspace/Coltura/tests/Fixtures/SiteAware/FakeSiteAwareEntity.php` : entite Doctrine minimale (`id`, `name`, `site`) utilisee **uniquement** en tests. Mapping Doctrine declare via un `#[ORM\Entity]` mais la table n'existe jamais en prod car la fixture n'est jamais chargee hors tests. **Alternative** : utiliser un schema DB dedie au dossier de tests, cree a la volee par un helper setUp. A trancher a l'implementation.
|
||||
|
||||
## 4. Fichiers à modifier
|
||||
|
||||
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/SitesModule.php` : ajouter la permission `sites.bypass_scope` dans `permissions()` :
|
||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/SitesModule.php` : ajouter la permission `sites.bypass_scope` dans `permissions()` :
|
||||
```php
|
||||
['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'],
|
||||
```
|
||||
**Note importante** : la methode `permissions()` signale l'existence de la permission mais c'est la commande `app:sync-permissions` (inchangee) qui la positionne en base.
|
||||
- `/home/m-tristan/workspace/Starseed/config/services.yaml` : aucun changement requis. `SiteScopedQueryExtension`, `SiteAwareInjectionProcessor` et `CurrentSiteProvider` sont autoconfigures via les `_defaults` du module. Le decorator du persist processor est declare via `#[AsDecorator]` ou via tag (cf. section 8).
|
||||
- `/home/m-tristan/workspace/Starseed/phpunit.dist.xml` : aucune modification requise si la config des fixtures de tests est autonome. Si `FakeSiteAwareEntity` necessite un mapping dedie, l'option la plus propre est un `doctrine.yaml.test` ajoute via `when@test`, sans polluer la config dev/prod (cf. Risque 3).
|
||||
- `/home/m-tristan/workspace/Coltura/config/services.yaml` : aucun changement requis. `SiteScopedQueryExtension`, `SiteAwareInjectionProcessor` et `CurrentSiteProvider` sont autoconfigures via les `_defaults` du module. Le decorator du persist processor est declare via `#[AsDecorator]` ou via tag (cf. section 8).
|
||||
- `/home/m-tristan/workspace/Coltura/phpunit.dist.xml` : aucune modification requise si la config des fixtures de tests est autonome. Si `FakeSiteAwareEntity` necessite un mapping dedie, l'option la plus propre est un `doctrine.yaml.test` ajoute via `when@test`, sans polluer la config dev/prod (cf. Risque 3).
|
||||
|
||||
## 5. Contrat `SiteAwareInterface`
|
||||
|
||||
@@ -459,7 +459,7 @@ A mitiger par un test qui genere une entite `FakeSiteAwareEntity` via un POST `a
|
||||
|
||||
### Risque 8 — Doc developpeur en francais vs anglais
|
||||
|
||||
Le fichier `docs/modules/site-aware.md` s'adresse aux developpeurs de Starseed. Il est redige en **francais**, aligne sur la convention projet (CLAUDE.md : "commentaires en francais, code en anglais"). Aucun extrait de code ne doit etre traduit, seules les explications.
|
||||
Le fichier `docs/modules/site-aware.md` s'adresse aux developpeurs de Coltura. Il est redige en **francais**, aligne sur la convention projet (CLAUDE.md : "commentaires en francais, code en anglais"). Aucun extrait de code ne doit etre traduit, seules les explications.
|
||||
|
||||
## 12. Plan de tests
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,113 +0,0 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M0
|
||||
nom: "Gestion des catégories"
|
||||
ecran: gestion-categories
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0
|
||||
date_redaction: 2026-05-22
|
||||
|
||||
# === LIENS ===
|
||||
maquette_figma: null # pas de Figma — UI admin standard
|
||||
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]
|
||||
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||
lien_spec_back: ./spec-back.md
|
||||
|
||||
# === VALIDATION CLIENT #1 ===
|
||||
client_validation_1:
|
||||
statut: validee # V0 client validée le 22/05/2026
|
||||
date: 2026-05-22
|
||||
canal: ecrit
|
||||
valide_par: "Matthieu (CP MALIO) — validation implicite, périmètre projet"
|
||||
resume: "Module 0 — Gestion des catégories. Page admin (datatable + drawer). 2 champs (Nom + Type), 3 actions (Ajouter / Consulter / Modifier). Admin only."
|
||||
trace_archivee: "uploads/c4ebb6b4-M0categories.docx (V0 d'origine .docx) — restitué ci-dessous en Markdown."
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_taskgroup_id: 22
|
||||
lesstime_project_id: 6 # ERP / Starseed
|
||||
statut_global: en_dev # tickets créés en backlog Lesstime le 2026-05-26
|
||||
---
|
||||
|
||||
# Module 0 — Gestion des catégories (V0 front)
|
||||
|
||||
> **Origine** : spec front V0 livrée le 22/05/2026 (`c4ebb6b4-M0categories.docx` + `f665acfb-M0categoriesV0.pdf`). Restitution Markdown fidèle pour intégration au workflow MALIO. Le contenu original n'est pas modifié — toute reformulation et précision (en particulier côté back) vit dans [`spec-back.md`](./spec-back.md).
|
||||
|
||||
## But
|
||||
|
||||
Permettre à un administrateur Starseed de gérer un référentiel de **catégories** depuis l'interface admin du logiciel. Ces catégories seront utilisées plus tard pour classifier les tiers (clients, fournisseurs, prestataires).
|
||||
|
||||
## Accès
|
||||
|
||||
- **Depuis** : menu principal → **Administration** → entrée « Gestion des catégories »
|
||||
- **Rôles autorisés** : **Admin uniquement** (Bureau / Compta / Commerciale / Usine n'ont **aucun** accès, ni lecture ni écriture).
|
||||
|
||||
## Navigation
|
||||
|
||||
L'écran est la page d'entrée du Module **Administration**. Titre de la page : « **Gestion des catégories** ».
|
||||
|
||||
- Affichage principal : un **datatable** listant toutes les catégories existantes.
|
||||
- **Clic sur une ligne** → ouverture d'un **drawer** latéral en mode **consultation / modification** (cf. § Action « Consulter »).
|
||||
- **Bouton « + Ajouter »** (en haut à droite du datatable) → ouverture d'un **drawer** en mode **création** (cf. § Action « Ajouter »).
|
||||
- Pas d'onglet, pas de pagination explicite (volumétrie cible faible).
|
||||
|
||||
## Actions
|
||||
|
||||
| Action | Déclencheur | Comportement |
|
||||
|---|---|---|
|
||||
| **Ajouter** | Clic sur le bouton « + Ajouter » | Ouvre le drawer en mode création, formulaire vide. Validation → POST → la catégorie apparaît dans le datatable. |
|
||||
| **Consulter** | Clic sur une ligne du datatable | Ouvre le drawer avec les champs pré-remplis en lecture (et passage en édition si l'utilisateur modifie un champ). |
|
||||
| **Modifier** | Modification d'un champ dans le drawer ouvert en consultation | Validation → PATCH → la ligne du datatable se met à jour. |
|
||||
|
||||
> **Note V0** : la **suppression** n'était pas mentionnée dans la V0 client. Côté workflow MALIO, suite à la revue back (cf. `spec-back.md` § Q3), un soft delete est ajouté (corbeille logique). L'UI peut intégrer ce point lors d'une V1 — au M0 le bouton « Supprimer » n'est pas obligatoire, mais doit être facilement ajoutable.
|
||||
|
||||
## Formulaire — Champs
|
||||
|
||||
Le formulaire (drawer) contient **2 champs**, tous deux obligatoires :
|
||||
|
||||
| Champ | Type | Obligatoire | Contenu / valeur par défaut | Règle |
|
||||
|---|---|---|---|---|
|
||||
| **Nom** | Texte libre | **Oui** | vide à la création | Pas de règle métier détaillée en V0. Détails côté back : RG-1.02 / RG-1.03 / RG-1.04 (obligatoire, trim, longueur 2–120). |
|
||||
| **Type de catégorie** | Select | **Oui** | vide à la création | Le contenu du Select n'était pas précisé en V0. Décision back : entité de référence `CategoryType` séparée (RG-1.05 / RG-1.06). Le référentiel sera alimenté plus tard (cf. HP-1 dans `spec-back.md`). |
|
||||
|
||||
> **Note V0** : la V0 ne précisait ni si le `Type de catégorie` est un enum hardcodé ni si c'est une autre entité. Décision tranchée côté back avant découpe en tickets : **entité de référence** (`category_types`), table créée vide au M0.
|
||||
|
||||
## Permissions par rôle
|
||||
|
||||
| Rôle | Vue (`GET`) | Création (`POST`) | Édition (`PATCH`) | Suppression (`DELETE`) |
|
||||
|---|---|---|---|---|
|
||||
| **Admin** | ✅ | ✅ | ✅ | ✅ (soft delete — ajout post-V0) |
|
||||
| Bureau | ❌ | ❌ | ❌ | ❌ |
|
||||
| Compta | ❌ | ❌ | ❌ | ❌ |
|
||||
| Commerciale | ❌ | ❌ | ❌ | ❌ |
|
||||
| Usine | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
→ Les rôles non-Admin ne voient **pas** l'entrée de menu et reçoivent **403** sur toute requête vers les endpoints `/api/categories/*` (cf. RG-1.01 dans `spec-back.md`).
|
||||
|
||||
## Composants UI à utiliser (Starseed / `@malio/layer-ui`)
|
||||
|
||||
- **Datatable** : `<MalioDataTable>` (avec colonnes `Nom` + `Type` + actions, tri par défaut sur Nom).
|
||||
- **Drawer** : drawer latéral standard `@malio/layer-ui` (à confirmer côté front avec le composant exact).
|
||||
- **Input texte** : `<MalioInputText>` pour le champ Nom.
|
||||
- **Select** : `<MalioSelect>` pour le champ Type de catégorie, alimenté par `GET /api/category_types`.
|
||||
- **Bouton** : `<MalioButton>` (« + Ajouter », « Enregistrer », « Annuler »).
|
||||
- **Toasts succès / erreur** : standards via `useApi()`.
|
||||
|
||||
## Points laissés ouverts par la V0 (résolus côté back)
|
||||
|
||||
| # | Zone d'ombre V0 | Résolution (cf. `spec-back.md`) |
|
||||
|---|---|---|
|
||||
| 1 | Suppression non mentionnée | **Soft delete** ajouté (RG-1.12 + RG-1.13). UI peut ajouter le bouton plus tard. |
|
||||
| 2 | Unicité du nom non précisée | **Unicité sur `(name, type)` case-insensitive**, parmi non-soft-deleted (RG-1.07). |
|
||||
| 3 | Nature du `Type de catégorie` (enum vs entité) | **Entité de référence** `CategoryType` (table vide au M0, créée par migration). |
|
||||
| 4 | Volumétrie & pagination | **300 max** → pagination front (`<MalioDataTable>`), pas de pagination serveur. Tri serveur `name ASC` par défaut. |
|
||||
| 5 | Audit / traçabilité | Pattern `#[Auditable]` Starseed standard. Trace dans la table `audit_log` (qui / quoi / quand / diff). **Pas** de colonnes `created_by` / `updated_by` sur l'entité (cohérent avec User / Role dans Starseed). Historique consultable via `/api/audit-log?entityType=Category&entityId={id}`. |
|
||||
| 6 | Référencement par d'autres entités | **Aucune FK entrante au M0.** Les modules Tiers (M-Clients / M-Fournisseurs / M-Prestas) ajouteront leur propre `category_id` plus tard. |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tickets Lesstime générés
|
||||
|
||||
**TaskGroup Lesstime** : `#22 — M0 — Gestion des catégories` (projet `ERP / Starseed`, projectId=6)
|
||||
|
||||
> Détail complet, table des tickets et action manuelle dans Lesstime → voir [`spec-back.md § Tickets Lesstime générés`](./spec-back.md#-tickets-lesstime-générés).
|
||||
@@ -1,146 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,633 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,79 +0,0 @@
|
||||
# Cahier de test back — M1 Répertoire clients (ticket ERP-60 / #478)
|
||||
|
||||
Mapping **toutes les RG (§ 7) → test(s) PHPUnit**, à jour après ERP-60.
|
||||
|
||||
Légende source : `ERP-55` `ERP-56` `ERP-57` `ERP-58` = tests écrits par les wagons
|
||||
précédents ; **`ERP-60`** = tests ajoutés par ce ticket (stratégie « combler les
|
||||
trous, zéro duplication »).
|
||||
|
||||
## Stratégie
|
||||
|
||||
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) sont
|
||||
**délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le
|
||||
merge de la stack.
|
||||
|
||||
## Mapping RG → test
|
||||
|
||||
| RG | Intitulé | Test(s) | Source |
|
||||
|----|----------|---------|--------|
|
||||
| ~~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~~ | _(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 |
|
||||
| RG-1.10 | ≥ 1 site sur adresse → 422 | `ClientSubResourceApiTest::testPostAddressWithoutSiteReturns422` | ERP-57 |
|
||||
| RG-1.11 | billingEmail obligatoire ssi isBilling → 422 (Assert\Callback + CHECK filet) | `ClientAddressTest::testBillingAddressRequiresBillingEmail` ; `::testNonBillingAddressRejectsBillingEmail` | ERP-60 / **ERP-76** |
|
||||
| RG-1.12 | Virement → banque obligatoire → 422 | `ClientProcessorTest::testVirementWithoutBankIsUnprocessable` ; `::testVirementWithBankPasses` (unit) | ERP-55 |
|
||||
| RG-1.13 | LCR → ≥ 1 RIB ; DELETE dernier RIB en LCR → 409 | `ClientProcessorTest::testLcrWithoutRibIsUnprocessable` / `::testLcrWithRibPasses` (unit) ; `ClientSubResourceApiTest::testDeleteLastRibUnderLcrReturns409` / `::testDeleteRibNonLcrReturns204` | ERP-55 / ERP-57 |
|
||||
| RG-1.14 | ≥ 1 bloc Contact pour finaliser l'onglet | **Front-driven (pas de state machine back).** Back voisin : `ClientSubResourceApiTest::testDeleteLastContactReturns409` | ERP-57 |
|
||||
| RG-1.15 | ~~Unicité SIREN~~ supprimée (Q4) — SIREN partageable | `ClientUniquenessTest::testDuplicateSirenIsAllowed` ; `ClientMigrationTest::testNoSirenOrEmailUniqueIndex` | **ERP-60** |
|
||||
| RG-1.16 | companyName unique (case-insensitive) parmi actifs → 409 | `ClientApiTest::testPostDuplicateCompanyNameReturns409` ; `ClientMigrationTest::testCompanyNameActivePartialIndexExistsExactlyOnce` | ERP-55 / **ERP-60** |
|
||||
| RG-1.17 | ~~Unicité email~~ supprimée (Q4) — email partageable | `ClientUniquenessTest::testDuplicateEmailIsAllowed` ; `ClientMigrationTest::testNoSirenOrEmailUniqueIndex` | **ERP-60** |
|
||||
| RG-1.18 | companyName upper-cased serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testCompanyNameIsUppercased` (unit) | ERP-55 |
|
||||
| RG-1.19 | firstName/lastName capitalize serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testPersonNameIsTitleCased` (unit) ; `ClientSubResourceApiTest::testPostContactNormalizesFields` | ERP-55 / ERP-57 |
|
||||
| RG-1.20 | Téléphones chiffres-seuls serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testPhoneKeepsOnlyDigits` (unit) ; `ClientFormulaireMainTest::testPostPersistsSecondaryPhoneNormalized` (secondary) | ERP-55 / **ERP-60** |
|
||||
| RG-1.21 | email lowercase serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testEmailIsLowercased` (unit) ; `ClientSubResourceApiTest::testPostContactNormalizesFields` / `::testPostAddressNormalizesBillingEmail` | ERP-55 / ERP-57 |
|
||||
| RG-1.22 | Archive : permission `archive` + archivedAt + aucun autre champ | `ClientApiTest::testPatchArchiveSetsArchivedAtThenRestore` ; `::testPatchArchiveWithOtherFieldReturns422` ; `ClientProcessorTest` (unit, gating archive) | ERP-55 |
|
||||
| RG-1.23 | Restauration : archivedAt=null ; **409 si conflit d'unicité** | `ClientApiTest::testPatchArchiveSetsArchivedAtThenRestore` (cas nominal) ; **`ClientArchiveTest::testRestoreConflictReturns409`** (409 restauration, gap P1) | ERP-55 / **ERP-60** |
|
||||
| RG-1.24 | Liste exclut les archivés par défaut | `ClientApiTest::testListSortedByCompanyNameAscAndExcludesArchived` | ERP-55 |
|
||||
| RG-1.25 | `?includeArchived=true` inclut les archivés | `ClientApiTest::testListIncludeArchivedReturnsArchived` | ERP-55 |
|
||||
| RG-1.26 | Tri par défaut companyName ASC | `ClientApiTest::testListSortedByCompanyNameAscAndExcludesArchived` | ERP-55 |
|
||||
| RG-1.27 | Timestampable/Blamable : created* figés, updated* mis à jour | `ClientAuditTest::testCreatedFrozenAndUpdatedByReflectsModifier` | **ERP-60** |
|
||||
| RG-1.28 | PATCH multi-groupes sans permission → 403 strict (tout le payload) | `ClientProcessorTest::testStrictMixWithAccountingFieldIsForbidden` / `::testAccountingFieldWithoutPermissionIsForbidden` (unit) ; **`ClientPatchStrictTest::testMixedGroupsPatchWithoutAccountingPermissionIsForbidden`** (fonctionnel) | ERP-55 / **ERP-60** |
|
||||
| RG-1.29 | Catégorie d'adresse limitée aux types SECTEUR/AUTRE | **Filtrage LECTURE = front-driven** (SearchFilter `GET /api/categories?categoryType.code[]=…`). **Validation ÉCRITURE** : `ClientAddress::validateCategoryTypes` (Assert\Callback) rejette une catégorie DISTRIBUTEUR/COURTIER en 422 (violation `categories`). Tests : `ClientAddressTest::testAddressRejectsDistributorCategory` / `::testAddressRejectsBrokerCategory` / `::testAddressAcceptsSectorCategory` / `::testAddressAcceptsOtherCategory` | **ERP-76** |
|
||||
|
||||
## Couvertures transverses
|
||||
|
||||
| Sujet | Test(s) | Source |
|
||||
|-------|---------|--------|
|
||||
| Audit iban/bic présents dans le diff (pas d'`#[AuditIgnore]`) | `ClientAuditTest::testRibCreateAuditIncludesIbanAndBic` | **ERP-60** |
|
||||
| Sécurité générique : 401 anonyme + 403 sans `commercial.clients.view` | `ClientSecurityTest` (collection + détail) ; `ClientExportControllerTest::testForbiddenWithoutClientsViewPermission` / `::testUnauthorizedWhenAnonymous` | **ERP-60** / ERP-58 |
|
||||
| Migration : index partiel unique présent (1 seul), pas de siren/email unique | `ClientMigrationTest` | **ERP-60** |
|
||||
| Référentiels comptables read-only (405 écriture, 401/403) | `ReferentialApiTest` | ERP-56 |
|
||||
| Export XLSX (colonnes accounting selon permission, 401/403) | `ClientExportControllerTest` | ERP-58 |
|
||||
|
||||
## Délégué à ERP-74 (#493) — NE PAS faire dans ERP-60
|
||||
|
||||
- **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**~~ : _(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
|
||||
|
||||
- ~~**RG-1.29 (validation écriture)**~~ — **résolu en ERP-76**. La validation
|
||||
d'écriture refuse désormais une catégorie de type `DISTRIBUTEUR`/`COURTIER` sur
|
||||
une `ClientAddress` (→ 422, violation `categories`) via l'Assert\Callback
|
||||
`ClientAddress::validateCategoryTypes`. Le filtrage de lecture reste
|
||||
front-driven (SearchFilter). Couvert par `ClientAddressTest`.
|
||||
- ~~**Violations CHECK → statut HTTP**~~ — **résolu en ERP-76**. Les règles
|
||||
d'adresse RG-1.06/07/08/11 sont désormais rejetées en **422** par des
|
||||
Assert\Callback applicatifs (`validateProspectExclusivity` /
|
||||
`validateBillingEmailPresence`) qui s'exécutent AVANT la base. Les CHECK
|
||||
Postgres (`chk_client_address_prospect_exclusive` /
|
||||
`chk_client_address_billing_email`) restent en filet de sécurité. Les tests
|
||||
`ClientAddressTest` assertent maintenant le 422 explicite (et non plus ≥ 400).
|
||||
@@ -1,135 +0,0 @@
|
||||
# M1 · Ticket 1/3 (Backend) — Supprimer le contact inline du `Client`
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Retirer de l'entité `Client` (et de la table `client`) les **5 champs du contact
|
||||
principal inline** : `firstName`, `lastName`, `phonePrimary`, `phoneSecondary`, `email`.
|
||||
La gestion des contacts passe désormais **exclusivement** par la sous-entité
|
||||
`ClientContact` (onglet « Contacts »), déjà en place et déjà porteuse des mêmes champs.
|
||||
|
||||
Le code M1 est **déjà livré en prod** : ce ticket inclut donc une **migration de données**
|
||||
(backfill) pour ne perdre aucune information de contact existante avant de supprimer les
|
||||
colonnes.
|
||||
|
||||
Contexte et justification : voir `README.md` du dossier `refonte-contact`.
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### IN
|
||||
|
||||
- Migration Doctrine : **backfill puis suppression** des 5 colonnes de `client`.
|
||||
- `Client` (entité) : supprimer les 5 propriétés, getters/setters, annotations ORM /
|
||||
`Assert` / `Groups`.
|
||||
- `ClientProcessor` : retirer les 5 champs de `MAIN_FIELDS`, `changedBusinessFields()`,
|
||||
`normalize()` ; supprimer `validateMainContact()` (RG-1.01 — n'a plus d'objet).
|
||||
- `DoctrineClientRepository::applySearch()` : trancher D1 (recherche) et l'appliquer.
|
||||
- `ClientExportController` : trancher D2 (colonnes export) et l'appliquer.
|
||||
- `ClientFixtures` : retirer les 5 paramètres inline de `ensureClient()` ; garantir que
|
||||
chaque client seedé possède au moins 1 `ClientContact` (déjà géré par `addContact()`).
|
||||
- Tests PHPUnit : mettre à jour / retirer les cas qui exercent ces 5 champs sur `Client`.
|
||||
|
||||
### OUT
|
||||
|
||||
- Toute modification de `ClientContact` / `ClientContactProcessor` : **inchangés** (c'est la
|
||||
cible, les champs y restent). `ClientFieldNormalizer` reste tel quel (toujours appelé par
|
||||
`ClientContactProcessor`).
|
||||
- Le front (formulaires, vues, types, i18n) → **ticket 2/3**.
|
||||
- Les specs (`spec-back.md`, `spec-front.md`, cahier de test) → **ticket 3/3**.
|
||||
|
||||
## 3. Fichiers à modifier
|
||||
|
||||
| Fichier | Action |
|
||||
|---|---|
|
||||
| `src/Module/Commercial/Domain/Entity/Client.php` | Supprimer props `firstName` (~l.158), `lastName` (~l.163), `phonePrimary` (~l.168), `phoneSecondary` (~l.172), `email` (~l.178) + leurs getters/setters (~l.329-382) + groupes `client:read`/`client:write:main` + `Assert\*`. |
|
||||
| `src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php` | Retirer les 5 clés de `MAIN_FIELDS` (~l.63) ; de `changedBusinessFields()` (~l.277-281) ; les 6 lignes de `normalize()` qui touchent email/phone/first/last/secondary (~l.433-441) ; supprimer `validateMainContact()` (~l.447-456) et son appel. |
|
||||
| `src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php` | `applySearch()` (~l.110-124) : appliquer **D1**. |
|
||||
| `src/Module/Commercial/Infrastructure/Controller/ClientExportController.php` | `buildHeaders()` (~l.94-114) + `buildRows()` (~l.121-143) : appliquer **D2**. |
|
||||
| `src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php` | `ensureClient()` (~l.357-395) : retirer firstName/lastName/phonePrimary/phoneSecondary/email ; conserver `addContact()`. |
|
||||
| `migrations/Version<timestamp>.php` (NOUVELLE) | Backfill + `DROP COLUMN` (cf. § 4). |
|
||||
| `tests/Module/Commercial/**` | Voir § 5. |
|
||||
|
||||
## 4. Migration Doctrine — backfill puis suppression
|
||||
|
||||
> Migration **modulaire** (`src/Module/Commercial/Infrastructure/Doctrine/Migrations/`) : ce
|
||||
> n'est PAS une migration d'initialisation, le schéma `client` / `client_contact` existe
|
||||
> déjà (règle ABSOLUE n°11).
|
||||
|
||||
### `up()`
|
||||
|
||||
1. **Backfill — ne créer un contact que pour les clients qui n'en ont aucun**, afin de ne
|
||||
pas dupliquer le contact déjà recopié à la création (`prefillFirstContact`) :
|
||||
|
||||
```sql
|
||||
INSERT INTO client_contact
|
||||
(client_id, first_name, last_name, phone_primary, phone_secondary, email, position, created_at, updated_at)
|
||||
SELECT c.id, c.first_name, c.last_name, c.phone_primary, c.phone_secondary, c.email, 0, NOW(), NOW()
|
||||
FROM client c
|
||||
WHERE NOT EXISTS (SELECT 1 FROM client_contact cc WHERE cc.client_id = c.id)
|
||||
AND (c.first_name IS NOT NULL OR c.last_name IS NOT NULL);
|
||||
```
|
||||
|
||||
> Le `WHERE ... first_name OU last_name IS NOT NULL` respecte le CHECK
|
||||
> `chk_client_contact_name`. Les rares clients sans nom de contact ET sans contact
|
||||
> existant ne reçoivent pas de ligne (cas théorique : `phone_primary`/`email` étaient
|
||||
> `NOT NULL` mais les noms nullables).
|
||||
|
||||
2. **Supprimer les 5 colonnes** :
|
||||
|
||||
```sql
|
||||
ALTER TABLE client
|
||||
DROP COLUMN first_name,
|
||||
DROP COLUMN last_name,
|
||||
DROP COLUMN phone_primary,
|
||||
DROP COLUMN phone_secondary,
|
||||
DROP COLUMN email;
|
||||
```
|
||||
|
||||
> Pas de `COMMENT ON COLUMN` à poser (on supprime). Vérifier qu'aucun index ne portait
|
||||
> sur `email` (l'index unique `uq_client_email_active` a déjà été supprimé — décision Q4 /
|
||||
> RG-1.17, cf. `ClientMigrationTest`).
|
||||
|
||||
### `down()` (best-effort)
|
||||
|
||||
1. Recréer les 5 colonnes (`phone_primary`/`email` en `NOT NULL` impose un défaut transitoire
|
||||
ou un re-remplissage depuis le contact `position = 0`).
|
||||
2. Re-remplir depuis `client_contact` (`position = 0`) si possible.
|
||||
3. Reposer les `COMMENT ON COLUMN` d'origine (textes RG-1.19/1.20/1.21/1.01/1.17 — cf.
|
||||
`migrations/Version20260601000000.php` l.251-255).
|
||||
|
||||
> `down()` ne peut pas restaurer parfaitement les données (ambiguïté si plusieurs contacts).
|
||||
> Documenter cette limite dans le docblock de la migration.
|
||||
|
||||
## 5. Tests à mettre à jour
|
||||
|
||||
| Fichier | Action |
|
||||
|---|---|
|
||||
| `tests/Module/Commercial/Api/ClientApiTest.php` | Retirer firstName/lastName/phone/email des payloads POST/PATCH `client` et des assertions JSON. |
|
||||
| `tests/.../ClientFormulaireMainTest.php` | Supprimer les tests RG-1.01 (firstName/lastName) et RG-1.02 (téléphones) **côté Client** — ils basculent côté `ClientContact` (couverts ailleurs). |
|
||||
| `tests/.../ClientExportControllerTest.php` | Aligner les en-têtes/lignes attendus sur **D2**. |
|
||||
| `tests/.../ClientMigrationTest.php` | Asserter que les 5 colonnes **n'existent plus** sur `client` ; vérifier le backfill (un client sans contact obtient bien 1 `client_contact`). |
|
||||
| `tests/.../ClientFieldNormalizerTest.php` | Conserver les tests du normalizer (toujours utilisé par `ClientContact`) ; retirer les cas spécifiques aux champs `Client` s'il y en a. |
|
||||
| RG-1.01/1.02 (matrice) | Ne plus tester sur `Client` ; vérifier qu'ils restent couverts sur `ClientContact` (RG-1.05). |
|
||||
|
||||
## 6. Décisions à trancher (cf. README § 3)
|
||||
|
||||
- **D1 — recherche** : recommandé = `LEFT JOIN client_contact` (fuzzy sur
|
||||
`companyName` + contact `first_name`/`last_name`/`email`). Attention au `DISTINCT` /
|
||||
risque de doublons de lignes si plusieurs contacts matchent (grouper par `client.id`).
|
||||
- **D2 — export** : recommandé = alimenter les colonnes contact depuis le contact de plus
|
||||
petit `position` (fetch-join `contacts` pour éviter le N+1).
|
||||
|
||||
## 7. Critères d'acceptation (DoD)
|
||||
|
||||
- [ ] Les colonnes `first_name`, `last_name`, `phone_primary`, `phone_secondary`, `email`
|
||||
n'existent plus sur la table `client`.
|
||||
- [ ] La migration est jouable sur une base seedée sans perte de contact (backfill vérifié)
|
||||
et `down()` documenté comme best-effort.
|
||||
- [ ] `Client`, `ClientProcessor`, `DoctrineClientRepository`, `ClientExportController`,
|
||||
`ClientFixtures` ne référencent plus les 5 champs.
|
||||
- [ ] D1 et D2 implémentées conformément à la décision validée.
|
||||
- [ ] `ClientContact` / `ClientContactProcessor` / `ClientFieldNormalizer` inchangés.
|
||||
- [ ] `make test` vert (notamment `tests/Architecture/ColumnsHaveSqlCommentTest` et
|
||||
`EntitiesAreTimestampableBlamableTest`).
|
||||
- [ ] `make php-cs-fixer-allow-risky` ne signale rien sur les fichiers touchés.
|
||||
- [ ] Aucune régression du contrat de sérialisation : capturer le JSON réel de
|
||||
`GET /api/clients/{id}` et vérifier l'absence des 5 champs (réflexe RETEX M1).
|
||||
@@ -1,58 +0,0 @@
|
||||
# Prompt d'implémentation — M1 · Ticket 1/3 (Backend)
|
||||
|
||||
Tu travailles sur le projet **Starseed** (Symfony 8 / API Platform 4 / Doctrine / PostgreSQL).
|
||||
Lis `CLAUDE.md` et `.claude/rules/backend.md` avant de coder. Commentaires en français,
|
||||
code en anglais, `declare(strict_types=1);` partout.
|
||||
|
||||
## Mission
|
||||
|
||||
Supprimer le **contact principal inline** de l'entité `Client` : les 5 champs
|
||||
`firstName`, `lastName`, `phonePrimary`, `phoneSecondary`, `email`. Les contacts sont gérés
|
||||
uniquement via la sous-entité `ClientContact` (onglet Contacts), déjà en place. Le code est
|
||||
déjà en prod → migration avec **backfill** avant `DROP`.
|
||||
|
||||
La spec détaillée du ticket est dans `docs/specs/M1-clients/refonte-contact/M1-ticket-01-back.md`.
|
||||
Lis-la en entier, ainsi que le `README.md` du même dossier (décision + RG impactées + D1/D2).
|
||||
|
||||
## Étapes
|
||||
|
||||
1. **Explorer** : `Client.php`, `ClientProcessor.php`, `DoctrineClientRepository.php`,
|
||||
`ClientExportController.php`, `ClientFixtures.php`, et `ClientContact.php` (pour confirmer
|
||||
que la cible porte bien les mêmes champs).
|
||||
2. **Demander la validation des décisions D1 (recherche) et D2 (export)** avant de coder —
|
||||
défauts recommandés : D1 = LEFT JOIN sur `client_contact`, D2 = colonnes export depuis le
|
||||
contact `position` minimal. Ne pas inventer un autre comportement.
|
||||
3. **Migration** (`src/Module/Commercial/Infrastructure/Doctrine/Migrations/`) : backfill
|
||||
`INSERT INTO client_contact ... WHERE NOT EXISTS(...)` puis `ALTER TABLE client DROP COLUMN ...`
|
||||
(les 5). `down()` best-effort documenté. Voir le SQL exact dans la spec § 4.
|
||||
4. **Entité** : retirer les 5 props + getters/setters + `#[ORM\Column]` + `#[Assert\*]` +
|
||||
`#[Groups(['client:read','client:write:main'])]`.
|
||||
5. **Processor** : retirer de `MAIN_FIELDS`, `changedBusinessFields()`, `normalize()` ;
|
||||
supprimer `validateMainContact()` et son appel.
|
||||
6. **Repository** : `applySearch()` selon D1.
|
||||
7. **Export** : `buildHeaders()` / `buildRows()` selon D2.
|
||||
8. **Fixtures** : alléger `ensureClient()` ; garder `addContact()`.
|
||||
9. **Tests** : mettre à jour `ClientApiTest`, `ClientFormulaireMainTest`,
|
||||
`ClientExportControllerTest`, `ClientMigrationTest`, `ClientFieldNormalizerTest`
|
||||
(cf. spec § 5). Ajouter une assertion que le backfill crée bien un contact pour un client
|
||||
qui n'en avait pas.
|
||||
|
||||
## Garde-fous
|
||||
|
||||
- Ne touche **pas** `ClientContact`, `ClientContactProcessor`, `ClientFieldNormalizer`.
|
||||
- Respecte les règles ABSOLUES : pagination, `#[Auditable]`, COMMENT ON COLUMN (ici on
|
||||
supprime → pas de commentaire à poser, mais ne pas casser le garde-fou).
|
||||
- Les RG-1.01 et RG-1.02 disparaissent **du Client** : leur équivalent (RG-1.05 / RG-1.14)
|
||||
vit déjà sur `ClientContact`, ne le duplique pas.
|
||||
|
||||
## Vérification finale (obligatoire avant de dire « fini »)
|
||||
|
||||
```bash
|
||||
make db-reset && make migration-migrate # migration rejouable sur base fraîche
|
||||
make test # PHPUnit vert
|
||||
make php-cs-fixer-allow-risky # lint
|
||||
```
|
||||
|
||||
Puis capture le JSON réel de `GET /api/clients/{id}` (avec un JWT) et confirme que les 5
|
||||
champs ont disparu de la réponse et que `contacts[]` porte bien l'info (réflexe RETEX M1 :
|
||||
on valide sur le contrat réel, pas sur les annotations).
|
||||
@@ -1,74 +0,0 @@
|
||||
# M1 · Ticket 2/3 (Frontend) — Retirer le bloc contact principal des écrans Client
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Retirer le **bloc « contact principal »** (Nom, Prénom, Téléphone, Téléphone 2, Email) des
|
||||
trois écrans Client — **création**, **consultation**, **modification** — ainsi que des
|
||||
types, mappeurs, validations et clés i18n associés. La saisie des contacts se fait
|
||||
désormais uniquement dans l'**onglet « Contacts »** (composant `ClientContactBlock`, déjà
|
||||
en place et inchangé).
|
||||
|
||||
Dépend du **ticket 1/3 (back)** : l'API ne renvoie/n'accepte plus ces 5 champs sur `client`.
|
||||
Contexte : voir `README.md` du dossier `refonte-contact`.
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### IN — fichiers `frontend/modules/commercial/`
|
||||
|
||||
| Fichier | Action |
|
||||
|---|---|
|
||||
| `pages/clients/new.vue` | Supprimer le bloc principal Nom/Prénom/Téléphones/Email (~l.27-63), l'état `main.firstName/lastName/email`, `mainPhones` (~l.445-459), la fonction `prefillFirstContact()` (~l.658-665) et son appel, le mapping payload POST `phonePrimary/phoneSecondary` (~l.513-524). Adapter `isMainValid` (~l.479-493) : la validation principale ne porte plus que sur `companyName` (+ relation/catégories selon RG existantes). L'onglet **Contacts** devient le point de saisie des coordonnées ; garantir au moins un `ClientContactBlock` vide au départ. |
|
||||
| `pages/clients/[id]/edit.vue` | Supprimer les 5 champs du bloc principal (~l.32-73). `mapMainDraft()` et `buildMainPayload()` ne portent plus ces champs. L'onglet Contacts reste éditable. |
|
||||
| `pages/clients/[id]/index.vue` | Supprimer l'affichage lecture seule des 5 champs du bloc principal (~l.49-104, partie contact). Conserver l'onglet Contacts (lecture seule). |
|
||||
| `types/clientForm.ts` | `MainFormDraft` : retirer `firstName`, `lastName`, `email`, `phonePrimary`, `phoneSecondary`, `hasSecondaryPhone`. Garder `ContactFormDraft` (inchangé). |
|
||||
| `types/clientConsultation.ts` | `ClientDetail` : retirer `firstName/lastName/phonePrimary/phoneSecondary/email` (les commentaires « Contact principal »). Garder `ContactRead`. |
|
||||
| `utils/clientEdit.ts` | `mapMainDraft()` et `buildMainPayload()` : retirer les 5 champs. Garder `buildContactPayload()`. |
|
||||
| `utils/clientConsultation.ts` | Retirer toute lecture des 5 champs inline du client (garder `mapContactToDraft`, `contactOptionsOf`). |
|
||||
| `i18n/locales/fr.json` | Retirer `commercial.clients.form.main.firstName/lastName/email/phonePrimary/phoneSecondary/addPhone`. **Conserver** tout le bloc `commercial.clients.form.contact.*`. Vérifier qu'aucune autre vue ne référence les clés retirées. |
|
||||
| `**/__tests__/*.spec.ts` | Mettre à jour `clientFormRules.spec.ts`, `clientEdit.spec.ts`, `clientConsultation.spec.ts` (cf. § 4). |
|
||||
|
||||
### OUT
|
||||
|
||||
- `ClientContactBlock.vue`, l'onglet Contacts, `useClient`, la liste/répertoire
|
||||
(`pages/clients/index.vue` — ses colonnes n'affichent déjà pas le contact inline) :
|
||||
**inchangés**.
|
||||
- Le back → ticket 1/3. Les specs → ticket 3/3.
|
||||
|
||||
## 3. Comportement attendu après modification
|
||||
|
||||
- **Création** : le formulaire principal demande l'entreprise (et relation/catégories selon
|
||||
l'existant), plus de Nom/Prénom/Téléphone/Email inline. L'utilisateur renseigne les
|
||||
coordonnées dans l'onglet **Contacts**. La création reste valide tant qu'il y a
|
||||
`companyName` **et** ≥ 1 bloc Contact valide (Nom OU Prénom) — RG-1.05/RG-1.14 inchangées.
|
||||
- **Consultation** : plus de bloc contact principal ; l'onglet Contacts affiche les
|
||||
contacts.
|
||||
- **Modification** : idem ; le PATCH du groupe `client:write:main` n'envoie plus les 5
|
||||
champs.
|
||||
|
||||
## 4. Tests Vitest à mettre à jour
|
||||
|
||||
- `clientFormRules.spec.ts` : la validité du « principal » ne dépend plus de
|
||||
firstName/email/phone ; conserver `isContactNamed()` (RG-1.05) sur les blocs Contacts.
|
||||
- `clientEdit.spec.ts` : `buildMainPayload()` ne contient plus les 5 champs ; `mapMainDraft()`
|
||||
non plus.
|
||||
- `clientConsultation.spec.ts` : retirer les assertions sur les 5 champs inline.
|
||||
|
||||
## 5. Tips & rappels projet
|
||||
|
||||
- `useApi()` obligatoire (jamais `$fetch`/`ofetch`). Composants `Malio*` obligatoires.
|
||||
- État de tableau jamais dans l'URL (règle inchangée).
|
||||
- Les valeurs sont **normalisées côté serveur** (Capitalize / chiffres / lowercase) : le
|
||||
front envoie la saisie et réaffiche la valeur renvoyée — ne pas réintroduire de
|
||||
normalisation front.
|
||||
- Ne pas créer de clé i18n orpheline ni laisser de clé `form.main.*` morte.
|
||||
|
||||
## 6. Critères d'acceptation (DoD)
|
||||
|
||||
- [ ] Les 3 écrans n'affichent plus Nom/Prénom/Téléphone/Téléphone 2/Email en bloc principal.
|
||||
- [ ] Le parcours de création fonctionne avec `companyName` + onglet Contacts (≥ 1 contact).
|
||||
- [ ] `MainFormDraft` / `ClientDetail` ne déclarent plus les 5 champs ; `mapMainDraft` /
|
||||
`buildMainPayload` non plus.
|
||||
- [ ] Aucune clé i18n `form.main.firstName/lastName/email/phone*` restante ni référencée.
|
||||
- [ ] `make nuxt-test` vert.
|
||||
- [ ] Vérification visuelle du golden path (`make dev-nuxt`, port 3004) : création →
|
||||
consultation → modification d'un client sans bloc contact inline.
|
||||
@@ -1,47 +0,0 @@
|
||||
# Prompt d'implémentation — M1 · Ticket 2/3 (Frontend)
|
||||
|
||||
Projet **Starseed** (Nuxt 4 / Vue 3 Composition API / TypeScript / @malio/layer-ui).
|
||||
Lis `CLAUDE.md` et `.claude/rules/frontend.md` avant de coder. Commentaires en français,
|
||||
code en anglais, 4 espaces d'indentation.
|
||||
|
||||
## Mission
|
||||
|
||||
Retirer le **bloc « contact principal »** (Nom, Prénom, Téléphone, Téléphone 2, Email) des
|
||||
écrans Client (création / consultation / modification) et de tout le code associé (types,
|
||||
mappeurs, validations, i18n). Les contacts restent gérés par l'onglet **Contacts**
|
||||
(`ClientContactBlock`, inchangé).
|
||||
|
||||
Spec détaillée : `docs/specs/M1-clients/refonte-contact/M1-ticket-02-front.md` (lis-la en
|
||||
entier + le `README.md` du dossier). Ce ticket dépend du ticket back (l'API ne porte plus
|
||||
les 5 champs sur `client`).
|
||||
|
||||
## Étapes
|
||||
|
||||
1. Explorer `frontend/modules/commercial/` : `pages/clients/new.vue`, `[id]/edit.vue`,
|
||||
`[id]/index.vue`, `types/clientForm.ts`, `types/clientConsultation.ts`,
|
||||
`utils/clientEdit.ts`, `utils/clientConsultation.ts`, `i18n/locales/fr.json`.
|
||||
2. Supprimer le bloc principal des 3 écrans + l'état réactif `main.firstName/lastName/email`,
|
||||
`mainPhones`, `prefillFirstContact()`.
|
||||
3. Adapter `isMainValid` : ne dépend plus que de `companyName` (+ relation/catégories selon
|
||||
l'existant). La garantie « ≥ 1 contact valide » reste portée par l'onglet Contacts.
|
||||
4. Nettoyer les types (`MainFormDraft`, `ClientDetail`) et les mappeurs (`mapMainDraft`,
|
||||
`buildMainPayload`, `clientConsultation`).
|
||||
5. Retirer les clés i18n `form.main.firstName/lastName/email/phonePrimary/phoneSecondary/addPhone` ;
|
||||
vérifier par recherche qu'aucune vue ne les utilise plus. **Garder** `form.contact.*`.
|
||||
6. Mettre à jour les specs Vitest (`clientFormRules`, `clientEdit`, `clientConsultation`).
|
||||
|
||||
## Garde-fous
|
||||
|
||||
- `useApi()` uniquement ; composants `Malio*` uniquement ; pas d'état tableau dans l'URL.
|
||||
- Ne touche pas `ClientContactBlock.vue`, l'onglet Contacts, ni la liste/répertoire.
|
||||
- Pas de normalisation front (le serveur normalise).
|
||||
|
||||
## Vérification finale
|
||||
|
||||
```bash
|
||||
make nuxt-test # Vitest vert
|
||||
make dev-nuxt # port 3004 — golden path manuel
|
||||
```
|
||||
|
||||
Golden path à vérifier dans le navigateur : créer un client (entreprise + 1 contact dans
|
||||
l'onglet Contacts), le consulter, le modifier — sans aucun bloc contact inline.
|
||||
@@ -1,51 +0,0 @@
|
||||
# M1 · Ticket 3/3 (Specs) — Acter la suppression du contact inline dans les specs M1
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Mettre à jour la **documentation fonctionnelle/technique M1** pour refléter la décision :
|
||||
le contact principal inline est supprimé du `Client`, les contacts vivent uniquement dans
|
||||
`ClientContact`. Les specs sont la **source de vérité** du projet (cf. `workflow.md`) : elles
|
||||
doivent décrire le modèle cible, pas l'ancien.
|
||||
|
||||
> Idéalement réalisé **avant** les tickets 1 et 2 (la spec guide le code), mais peut être
|
||||
> fait en parallèle. À minima, ne pas merger le code sans aligner la spec.
|
||||
|
||||
## 2. Fichiers à modifier
|
||||
|
||||
| Fichier | Sections concernées |
|
||||
|---|---|
|
||||
| `docs/specs/M1-clients/spec-back.md` | § 3.1 diagramme E-R (retirer les 5 colonnes du bloc `client`) ; § 3.2 migration SQL `CREATE TABLE client` (retirer `first_name`/`last_name`/`phone_primary`/`phone_secondary`/`email` + leurs COMMENT) ; § 3.4 squelette entité `Client` (retirer les 5 props) ; § 4.3 exemple payload `POST /api/clients` (retirer les 5 champs) ; § 4.1 filtre `?search=` (refléter D1) ; § 4.6 export (refléter D2) ; § 7 RG (voir § 3 ci-dessous) ; § 8 cahier de tests (déplacer RG-1.01/1.02 vers ClientContact). |
|
||||
| `docs/specs/M1-clients/spec-front.md` | « Formulaire principal » (l.85-103) : retirer les lignes Nom/Prénom/Téléphone/Téléphone 2/Email ; écrans Consultation / Modification ; règles de formatage. Préciser que les coordonnées se saisissent dans l'onglet Contact. |
|
||||
| `docs/specs/M1-clients/cahier-test-back-M1.md` | Retirer / requalifier les lignes RG-1.01 et RG-1.02 (désormais couvertes par RG-1.05 sur `ClientContact`). |
|
||||
|
||||
## 3. Traitement des règles de gestion
|
||||
|
||||
- **RG-1.01** (firstName OU lastName obligatoire sur Client) → marquer **supprimée** :
|
||||
« Remplacée par RG-1.05 (≥ 1 contact valide) + RG-1.14 (≥ 1 bloc Contact). Le contact
|
||||
principal inline n'existe plus. »
|
||||
- **RG-1.02** (max 2 téléphones sur Client) → marquer **supprimée du Client** (reste
|
||||
applicable aux blocs `ClientContact`).
|
||||
- **RG-1.19 / RG-1.20 / RG-1.21** (normalisation) → préciser que le **scope `Client`
|
||||
disparaît** ; la normalisation reste sur `ClientContact` (et `ClientAddress.billingEmail`
|
||||
pour RG-1.21).
|
||||
- Ne **pas renuméroter** les RG existantes (éviter le drift avec le code/tests) : marquer
|
||||
« supprimée / requalifiée » en place, avec date et renvoi à la décision.
|
||||
|
||||
## 4. Forme
|
||||
|
||||
- Bumper la version des deux specs (`version: V0` → `V1`) dans le frontmatter, avec une
|
||||
entrée d'historique : date `2026-06-03`, motif « Suppression du contact inline du Client
|
||||
(refonte-contact) », auteur.
|
||||
- Ajouter un encadré « Décision » en tête de la section modèle de données, renvoyant au
|
||||
`README.md` du dossier `refonte-contact`.
|
||||
- Conserver le style des specs (sections numérotées, tableaux RG, exemples JSON).
|
||||
|
||||
## 5. Critères d'acceptation (DoD)
|
||||
|
||||
- [ ] `spec-back.md` : aucune mention des 5 colonnes inline dans le modèle `client`
|
||||
(E-R + SQL + entité + payload) ; RG-1.01/1.02 marquées supprimées ; D1/D2 décrites.
|
||||
- [ ] `spec-front.md` : le formulaire principal ne liste plus les champs de contact ;
|
||||
l'onglet Contact est présenté comme seul lieu de saisie des coordonnées.
|
||||
- [ ] `cahier-test-back-M1.md` : RG-1.01/1.02 retirées/requalifiées.
|
||||
- [ ] Versions bumpées (V1) + historique daté dans les deux specs.
|
||||
- [ ] Cohérence vérifiée avec les tickets 1 et 2 (mêmes décisions D1/D2).
|
||||
@@ -1,38 +0,0 @@
|
||||
# Prompt d'implémentation — M1 · Ticket 3/3 (Specs)
|
||||
|
||||
Projet **Starseed**. Tâche **documentaire** : mettre à jour les specs M1 Clients pour acter
|
||||
la suppression du contact principal inline du `Client`. Les specs sont la source de vérité ;
|
||||
elles doivent décrire le modèle cible.
|
||||
|
||||
## Mission
|
||||
|
||||
Modifier `docs/specs/M1-clients/spec-back.md`, `spec-front.md` et `cahier-test-back-M1.md`
|
||||
pour retirer le contact inline du `Client` (5 champs `firstName/lastName/phonePrimary/
|
||||
phoneSecondary/email`) — les contacts vivent uniquement dans `ClientContact`.
|
||||
|
||||
Spec du ticket : `docs/specs/M1-clients/refonte-contact/M1-ticket-03-specs.md` (lis-la + le
|
||||
`README.md` du dossier, qui contient la décision, les RG impactées et les décisions D1/D2).
|
||||
|
||||
## Étapes
|
||||
|
||||
1. Lire les 3 fichiers de specs M1 visés, repérer toutes les occurrences des 5 champs
|
||||
(diagramme E-R, CREATE TABLE client, squelette entité, payload POST, filtre search,
|
||||
export, RG, cahier de test).
|
||||
2. Retirer les 5 colonnes du modèle `client` (E-R + SQL + entité + exemple JSON).
|
||||
3. Marquer **supprimées** RG-1.01 et RG-1.02 (renvoi à RG-1.05/RG-1.14 sur `ClientContact`),
|
||||
restreindre le scope de RG-1.19/1.20/1.21 à `ClientContact`. **Ne pas renuméroter** les RG.
|
||||
4. Refléter les décisions D1 (recherche) et D2 (export) une fois tranchées.
|
||||
5. Côté `spec-front.md` : retirer les champs de contact du formulaire principal ; présenter
|
||||
l'onglet Contact comme seul lieu de saisie.
|
||||
6. Bumper `version: V0 → V1` + ajouter une entrée d'historique datée (2026-06-03).
|
||||
|
||||
## Garde-fous
|
||||
|
||||
- Ne touche pas au code, uniquement aux `.md` de specs.
|
||||
- Garde le style existant (sections numérotées, tableaux RG, exemples JSON).
|
||||
- Cohérence stricte avec les tickets 1 (back) et 2 (front) : mêmes décisions D1/D2.
|
||||
|
||||
## Vérification
|
||||
|
||||
Relire les 3 fichiers : plus aucune mention des 5 champs inline dans le modèle `client` ;
|
||||
RG-1.01/1.02 marquées supprimées ; versions à V1 avec historique.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Amendement des tickets M2 existants — suppression du contact inline du `Supplier`
|
||||
|
||||
Les 14 tickets M2 (n° 84–97, groupe Lesstime « M2 — Répertoire fournisseurs ») ont été
|
||||
rédigés sur le modèle initial **avec** contact inline. La décision `refonte-contact` les
|
||||
amende : `Supplier` ne porte **plus** les 5 champs `firstName/lastName/phonePrimary/
|
||||
phoneSecondary/email` ; les contacts vivent uniquement dans `SupplierContact` (onglet
|
||||
Contacts). Comme M2 n'est pas codé, il suffit de **ne jamais créer** ces colonnes/champs.
|
||||
|
||||
## Bandeau injecté en tête des tickets impactés
|
||||
|
||||
> ⚠️ **AMENDEMENT 2026-06-03 — refonte-contact.** Le contact principal inline est
|
||||
> **supprimé** du `Supplier` : ne pas créer/saisir les colonnes ni les champs `firstName`,
|
||||
> `lastName`, `phonePrimary`, `phoneSecondary`, `email` sur l'entité/le formulaire
|
||||
> `Supplier`. Les contacts sont gérés **uniquement** via `SupplierContact` (onglet
|
||||
> Contacts). RG-2.01 et RG-2.02 sont supprimées (équivalent assuré par RG-2.04 / RG-2.13).
|
||||
> RG-2.12 ne s'applique qu'à `companyName` + `SupplierContact`. Décisions transverses
|
||||
> recherche (D1) et export (D2) : cf. `docs/specs/M1-clients/refonte-contact/README.md`.
|
||||
|
||||
## Tickets à amender
|
||||
|
||||
### Back
|
||||
|
||||
| Ticket | n° | Impact |
|
||||
|---|---|---|
|
||||
| migration BDD M2 (supplier + sous-collections) | #85 | Retirer `first_name/last_name/phone_primary/phone_secondary/email` du `CREATE TABLE supplier` et leurs `COMMENT ON COLUMN`. `supplier_contact` inchangé. |
|
||||
| entités + repositories M2 | #86 | `Supplier` : retirer les 5 props + `Assert\Callback` RG-2.01. `SupplierContact` inchangé. |
|
||||
| SupplierProvider + SupplierProcessor | #87 | Retirer la validation RG-2.01, la normalisation des champs inline, leur présence dans `MAIN_FIELDS` / changedFields. Recherche selon D1. |
|
||||
| export XLSX fournisseurs | #91 | Colonnes contact selon D2 (depuis le contact principal, ou supprimées). |
|
||||
| tests PHPUnit M2 | #92 | RG-2.01/2.02 testées sur `SupplierContact` (pas `Supplier`) ; contrat de sérialisation sans les 5 champs inline sur le supplier. |
|
||||
|
||||
### Front
|
||||
|
||||
| Ticket | n° | Impact |
|
||||
|---|---|---|
|
||||
| page Ajouter un fournisseur (`/suppliers/new`) + `useSupplierForm` | #94 | Retirer le bloc contact principal du formulaire + le pré-remplissage du 1er contact. Saisie des coordonnées dans l'onglet Contacts. |
|
||||
| page Consultation fournisseur (`/suppliers/{id}`) | #95 | Retirer l'affichage du bloc contact principal. |
|
||||
| page Modification fournisseur (`/suppliers/{id}/edit`) | #96 | Retirer les 5 champs du bloc principal ; payload `supplier:write:main` sans ces champs. |
|
||||
|
||||
### Léger
|
||||
|
||||
| Ticket | n° | Impact |
|
||||
|---|---|---|
|
||||
| page Répertoire fournisseurs + datatable | #93 | Recherche « nom / contact / email » selon D1. Datatable : colonnes inchangées (pas de contact inline en colonne). |
|
||||
| i18n + sidebar fournisseurs | #97 | Ne pas créer les clés i18n `form.main.firstName/lastName/email/phone*` (garder `form.contact.*`). |
|
||||
|
||||
## Tickets NON impactés
|
||||
|
||||
- #84 (taxonomie FOURNISSEUR), #88 (sous-ressources contacts/adresses/ribs —
|
||||
`SupplierContact` est la cible, inchangé), #89 (validators Information Commerciale /
|
||||
catégorie / RG-2.07-2.08), #90 (RBAC fournisseurs).
|
||||
|
||||
## Méthode d'amendement
|
||||
|
||||
Pour chaque ticket impacté : **préfixer** la description existante du bandeau ci-dessus
|
||||
(sans rien supprimer du contenu d'origine), via `mcp__lesstime__update-task`
|
||||
(`description` = bandeau + description actuelle). La méthode préserve l'historique et reste
|
||||
réversible (retirer le bandeau).
|
||||
@@ -1,55 +0,0 @@
|
||||
# M2 · Ticket Specs — Retirer le contact inline du `Supplier` dans les specs M2
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Mettre à jour les specs **M2 Fournisseurs** déjà rédigées pour **ne plus inclure** le contact
|
||||
principal inline sur le `Supplier`. M2 est le jumeau strict de M1 (`Supplier` /
|
||||
`SupplierContact` / `SupplierAddress` / `SupplierRib`) et n'est **pas encore codé** : il faut
|
||||
donc corriger la conception **en amont**, pour que les 14 tickets M2 « prêts à dev » soient
|
||||
implémentés directement sans les 5 colonnes inline.
|
||||
|
||||
> Pendant de M1 ticket 3/3, mais côté M2 : **aucune migration de suppression ni backfill** —
|
||||
> on retire simplement le contact inline du modèle cible. Contexte : `README.md` du dossier
|
||||
> `refonte-contact`.
|
||||
|
||||
## 2. Fichiers à modifier
|
||||
|
||||
| Fichier | Sections concernées |
|
||||
|---|---|
|
||||
| `docs/specs/M2-suppliers/spec-back.md` | § 3.1 diagramme E-R (l.175-179 : retirer les 5 colonnes du bloc `supplier`) ; § 3.2 `CREATE TABLE supplier` (l.227-231) ; § 3.4 squelette entité `Supplier` (l.496-517 : props + `Assert\Callback` RG-2.01) ; § 4 exemples payload POST/GET (l.782-805, 867-871) ; recherche `?search=` (l.847 : refléter D1) ; export (refléter D2) ; § contrat de sérialisation (l.725, 729) ; § 7 RG (voir § 3). |
|
||||
| `docs/specs/M2-suppliers/spec-front.md` | « Formulaire principal » (l.105-117 : retirer Nom/Prénom/Téléphone/Téléphone 2/Email) ; onglet « Contact » (l.140-157 : retirer la phrase de pré-remplissage depuis le formulaire principal, l.142) ; écrans Consultation/Modification ; règles de formatage (l.283-285) ; recherche (l.76 : refléter D1). |
|
||||
|
||||
## 3. Traitement des règles de gestion M2
|
||||
|
||||
- **RG-2.01** (firstName OU lastName obligatoire sur Supplier) → **supprimée** : remplacée
|
||||
par RG-2.04 (≥ 1 contact valide) + RG-2.13 (≥ 1 bloc Contact). Le contact inline n'existe
|
||||
plus sur `Supplier`.
|
||||
- **RG-2.02** (max 2 téléphones sur Supplier) → **supprimée du Supplier** (reste sur
|
||||
`SupplierContact`).
|
||||
- **RG-2.12** (normalisation Capitalize / chiffres / lowercase) → restreindre le scope :
|
||||
s'applique à `companyName` (UPPERCASE) et aux champs de `SupplierContact` ; **plus** aux
|
||||
champs inline du `Supplier` (qui disparaissent).
|
||||
- Ne pas renuméroter les RG : marquer « supprimée / requalifiée » en place, avec date.
|
||||
|
||||
## 4. Forme
|
||||
|
||||
- Bumper la version des deux specs M2 + entrée d'historique datée (2026-06-03, motif
|
||||
« Suppression du contact inline du Supplier — alignement refonte-contact M1 »).
|
||||
- Encadré « Décision » renvoyant au `README.md` du dossier `refonte-contact`.
|
||||
- Garder le style des specs M2.
|
||||
|
||||
## 5. Lien avec les tickets M2 existants
|
||||
|
||||
La mise à jour des specs doit être cohérente avec l'**amendement des tickets M2** (voir
|
||||
`M2-amendement-tickets.md`) : tickets back #85/#86/#87/#91/#92 et front #94/#95/#96 (+ #93/#97
|
||||
légers). Specs et tickets décrivent le **même** modèle cible (sans contact inline).
|
||||
|
||||
## 6. Critères d'acceptation (DoD)
|
||||
|
||||
- [ ] `spec-back.md` M2 : aucune mention des 5 colonnes inline dans le modèle `supplier`
|
||||
(E-R + SQL + entité + payloads + sérialisation) ; RG-2.01/2.02 marquées supprimées ;
|
||||
D1/D2 décrites.
|
||||
- [ ] `spec-front.md` M2 : formulaire principal sans champs de contact ; onglet Contact
|
||||
présenté comme seul lieu de saisie (sans pré-remplissage depuis le principal).
|
||||
- [ ] Versions bumpées + historique daté.
|
||||
- [ ] Cohérence avec l'amendement des tickets M2.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Prompt d'implémentation — M2 · Ticket Specs
|
||||
|
||||
Projet **Starseed**. Tâche **documentaire**. Mettre à jour les specs M2 Fournisseurs
|
||||
(`docs/specs/M2-suppliers/spec-back.md` + `spec-front.md`) pour retirer le contact principal
|
||||
inline du `Supplier` (5 champs `firstName/lastName/phonePrimary/phoneSecondary/email`).
|
||||
|
||||
M2 n'est **pas encore codé** : on corrige la conception en amont, **sans** migration ni
|
||||
backfill (contrairement à M1). Les contacts vivent uniquement dans `SupplierContact`.
|
||||
|
||||
Spec du ticket : `docs/specs/M1-clients/refonte-contact/M2-ticket-specs.md` (lis-la + le
|
||||
`README.md` du dossier).
|
||||
|
||||
## Étapes
|
||||
|
||||
1. Lire `spec-back.md` et `spec-front.md` M2 ; repérer toutes les occurrences des 5 champs
|
||||
(E-R l.175-179, CREATE TABLE supplier l.227-231, entité l.496-517, payloads l.782-805 /
|
||||
867-871, sérialisation l.725-729, RG-2.01/2.02/2.12, recherche, export, formulaire
|
||||
principal front l.105-117, pré-remplissage onglet Contact l.142).
|
||||
2. Retirer les 5 colonnes du modèle `supplier`.
|
||||
3. Marquer **supprimées** RG-2.01 et RG-2.02 (renvoi RG-2.04/RG-2.13) ; restreindre RG-2.12
|
||||
à `companyName` + `SupplierContact`. Ne pas renuméroter.
|
||||
4. Refléter D1 (recherche : LEFT JOIN supplier_contact recommandé) et D2 (export depuis le
|
||||
contact principal recommandé).
|
||||
5. Front : retirer les champs de contact du formulaire principal ; retirer la phrase de
|
||||
pré-remplissage du 1er bloc Contact ; présenter l'onglet Contact comme seul lieu de saisie.
|
||||
6. Bumper la version + historique daté (2026-06-03).
|
||||
|
||||
## Garde-fous
|
||||
|
||||
- Uniquement les `.md` de specs M2. Style existant conservé.
|
||||
- Cohérence stricte avec l'amendement des tickets M2 et avec la décision M1 (jumeau).
|
||||
|
||||
## Vérification
|
||||
|
||||
Relire les 2 specs : plus aucune mention des 5 champs inline dans le modèle `supplier` ;
|
||||
RG-2.01/2.02 supprimées ; versions bumpées.
|
||||
@@ -1,84 +0,0 @@
|
||||
# Refonte « contact » — suppression du contact inline des tiers (Client M1 + Supplier M2)
|
||||
|
||||
> Dossier de tickets transverse. Source de vérité de la décision et de son découpage.
|
||||
> Rédigé le 2026-06-03. Owner : Matthieu.
|
||||
|
||||
## 1. Décision
|
||||
|
||||
Le **contact « principal » inline** (les 5 colonnes plates `first_name`, `last_name`,
|
||||
`phone_primary`, `phone_secondary`, `email`) est **supprimé de l'entité tier** (`Client`,
|
||||
puis `Supplier`). La gestion des contacts passe **exclusivement** par la sous-entité
|
||||
dédiée (`ClientContact` / `SupplierContact`), c.-à-d. l'**onglet « Contacts »**.
|
||||
|
||||
### Pourquoi
|
||||
|
||||
- **Modèle unique, zéro duplication.** Aujourd'hui le contact est saisi deux fois : une
|
||||
fois dans le bloc principal (inline sur le tier) et une fois dans l'onglet Contacts
|
||||
(sous-entité). À la création, le front recopie même l'un dans l'autre
|
||||
(`prefillFirstContact`). Deux sources pour la même information = risque de divergence.
|
||||
- **Cohérence métier.** Un tier peut avoir plusieurs contacts ; il n'y a pas de raison
|
||||
qu'un seul soit « privilégié » au niveau de la table tier. La notion de contact
|
||||
appartient à la collection de contacts.
|
||||
- **Garantie préservée.** L'invariant « il y a toujours au moins un contact » est déjà
|
||||
assuré par la sous-entité : RG-1.05/RG-1.14 (M1) et RG-2.04/RG-2.13 (M2) imposent
|
||||
**≥ 1 bloc Contact valide** (Nom OU Prénom). Supprimer le contact inline ne crée donc
|
||||
aucun trou : le contact reste obligatoire, mais au bon endroit.
|
||||
|
||||
### Règles de gestion impactées
|
||||
|
||||
| RG | Avant | Après |
|
||||
|---|---|---|
|
||||
| RG-1.01 / RG-2.01 (firstName OU lastName obligatoire **sur le tier**) | sur `Client` / `Supplier` | **supprimée** du tier — équivalent assuré par RG-1.05 / RG-2.04 sur la sous-entité |
|
||||
| RG-1.02 / RG-2.02 (max 2 téléphones **sur le tier**) | sur le tier | **supprimée** du tier — reste sur la sous-entité |
|
||||
| RG-1.19/1.20/1.21 — RG-2.12 (normalisation Capitalize / chiffres / lowercase) | appliquée aux champs **du tier ET** de la sous-entité | ne s'applique plus aux champs du tier (qui n'existent plus) — **inchangée** sur la sous-entité |
|
||||
|
||||
## 2. Périmètre & découpage
|
||||
|
||||
### M1 — Clients (code DÉJÀ livré → suppression + migration de données)
|
||||
|
||||
| # | Ticket | Tag | Effort |
|
||||
|---|--------|-----|--------|
|
||||
| 1 | `M1-ticket-01-back` — supprimer le contact inline du `Client` (migration + backfill + entité + processor + provider + export + fixtures + tests) | Backend | M |
|
||||
| 2 | `M1-ticket-02-front` — retirer le bloc contact principal des écrans création / consultation / modification | Frontend | M |
|
||||
| 3 | `M1-ticket-03-specs` — acter la décision dans les specs M1 (back + front + cahier de test) | Maintenance | S |
|
||||
|
||||
### M2 — Fournisseurs (NON codé → on retire le contact inline dès la conception)
|
||||
|
||||
| # | Action | Tag | Effort |
|
||||
|---|--------|-----|--------|
|
||||
| 4 | `M2-ticket-specs` — mettre à jour les specs M2 déjà écrites (back + front) pour retirer le contact inline du `Supplier` | Maintenance | S |
|
||||
| — | `M2-amendement-tickets` — amender les tickets M2 existants (n° 84–97) impactés (migration, entités, processor, export, front, tests, i18n) | — | — |
|
||||
|
||||
> M2 ne nécessite **pas** de migration de suppression ni de backfill : il suffit de **ne
|
||||
> jamais créer** les 5 colonnes inline sur `supplier`. Le travail M2 est donc un
|
||||
> ajustement de specs + un amendement des tickets « prêts à dev ».
|
||||
|
||||
## 3. Décisions transverses à trancher (mêmes pour M1 et M2)
|
||||
|
||||
Deux comportements s'appuyaient sur les colonnes inline du tier. À la suppression, il faut
|
||||
choisir leur nouvelle source. Recommandation par défaut entre parenthèses.
|
||||
|
||||
- **D1 — Recherche serveur** (`?search=`). Aujourd'hui : fuzzy sur `companyName` +
|
||||
`lastName` + `email` **du tier**. Après suppression, deux options :
|
||||
- (a) restreindre la recherche à `companyName` seul (simple, mais perte de la recherche
|
||||
par contact) ;
|
||||
- (b) **[recommandé]** étendre la recherche en `LEFT JOIN` sur la sous-entité contact
|
||||
(`first_name` / `last_name` / `email` du contact), pour préserver l'UX « recherche par
|
||||
nom / contact / email » annoncée dans la barre de recherche.
|
||||
- **D2 — Colonnes de l'export XLSX** (Nom contact / Prénom / Téléphone / Téléphone 2 /
|
||||
Email). Après suppression :
|
||||
- (a) supprimer ces colonnes ;
|
||||
- (b) **[recommandé]** les alimenter depuis le **contact principal** (le contact de plus
|
||||
petit `position`), pour garder un export utile.
|
||||
|
||||
Ces deux décisions sont à valider par le métier (Matthieu) avant implémentation et sont
|
||||
rappelées dans chaque ticket concerné.
|
||||
|
||||
## 4. Fichiers de ce dossier
|
||||
|
||||
- `README.md` (ce fichier) — décision + découpage.
|
||||
- `M1-ticket-01-back.md` / `.prompt.md` — description + prompt d'implémentation.
|
||||
- `M1-ticket-02-front.md` / `.prompt.md`.
|
||||
- `M1-ticket-03-specs.md` / `.prompt.md`.
|
||||
- `M2-ticket-specs.md` / `.prompt.md`.
|
||||
- `M2-amendement-tickets.md` — bandeau d'amendement + liste des tickets M2 à mettre à jour.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,287 +0,0 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M1
|
||||
nom: "Répertoire clients"
|
||||
ecran: repertoire-clients
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V1
|
||||
# Historique : V1 (2026-06-03) — Refonte contact : suppression du bloc contact principal inline
|
||||
# (Nom/Prénom/Téléphone/Téléphone 2/Email retirés du formulaire principal et des écrans).
|
||||
# Saisie via l'onglet Contacts uniquement. Cf. docs/specs/M1-clients/refonte-contact/README.md
|
||||
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.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
|
||||
|
||||
# === VALIDATION CLIENT #1 ===
|
||||
client_validation_1:
|
||||
statut: validee
|
||||
date: 2026-05-22
|
||||
canal: ecrit
|
||||
valide_par: "Matthieu (CP MALIO) — validation implicite, périmètre projet"
|
||||
resume: "Module 1 — Répertoire clients. Page d'entrée Commercial. Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Information / Contact / Adresse / Comptabilité (Transport, Statistiques, Rapports, Échanges = placeholders blancs)."
|
||||
trace_archivee: "uploads/4a1b026f-M1-reportoire-clients.docx (V0 d'origine .docx)"
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_taskgroup_id: 23
|
||||
lesstime_project_id: 6
|
||||
statut_global: en_dev
|
||||
---
|
||||
|
||||
# Module 1 — Répertoire clients (V0 front)
|
||||
|
||||
> **Origine** : spec front V0 livrée le 22/05/2026 (`M1-reportoire-clients.docx`). Restitution Markdown pour intégration au workflow MALIO. Le contenu original n'est pas modifié — toute précision et toute décision (en particulier côté back) vit dans [`spec-back.md`](./spec-back.md).
|
||||
|
||||
## But
|
||||
|
||||
Permettre aux utilisateurs Starseed (selon rôle) de gérer le **répertoire des clients** de l'organisation : consultation, création, modification, archivage. Cette page est la **porte d'entrée du module Commercial**.
|
||||
|
||||
## Accès
|
||||
|
||||
- **Depuis** : menu principal → section **Commercial** → entrée « Répertoire clients »
|
||||
- **Rôles autorisés** :
|
||||
|
||||
| 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** | ❌ | ❌ | ❌ |
|
||||
|
||||
> **Note** : aligné sur le docx d'origine — Compta édite uniquement l'onglet Comptabilité (champs SIREN / TVA / Délai de règlement / Type de règlement / Banque / RIBs). Compta ne peut pas **créer** un client (pas de droit `manage` général), mais peut éditer la partie comptable d'un client existant créé par Admin ou Bureau.
|
||||
|
||||
## Navigation
|
||||
|
||||
L'écran est la page d'entrée du module **Commercial**. Titre : « **Répertoire clients** ».
|
||||
|
||||
- Affichage principal : un **datatable** listant tous les clients **actifs** de l'organisation (les clients archivés sont masqués par défaut — filtre UI dédié pour les voir).
|
||||
- **Clic sur une ligne** → bascule sur l'écran **Consultation client** (page dédiée, pas un drawer — cf. maquette Figma).
|
||||
- **Bouton « + Ajouter »** (en haut à droite) → bascule sur l'écran **Ajouter un client**.
|
||||
- **Bouton « Exporter »** (en haut à droite) → télécharge un **fichier XLSX** des clients **affichés** (cf. filtre actif). Format détaillé dans [`spec-back.md` § Export](./spec-back.md).
|
||||
|
||||
## Datatable du Répertoire
|
||||
|
||||
Composant : `<MalioDataTable>`. Colonnes (à raffiner avec Tristan en revue maquette) :
|
||||
|
||||
| Colonne | Source | Tri |
|
||||
|---|---|---|
|
||||
| **Nom entreprise** | `client.companyName` | ASC par défaut |
|
||||
| **Catégories** | liste des codes catégories séparés par `,` | Non |
|
||||
| **Site(s)** | sites rattachés à au moins une adresse (badges colorés) | Non |
|
||||
|
||||
> **Filtre archivés** : toggle UI en haut du datatable. Désactivé par défaut. État local (pas dans l'URL — cf. règle ABSOLUE Starseed n°6).
|
||||
|
||||
> **Pagination** : front via `<MalioDataTable>` (volumétrie cible faible — quelques centaines). Tri serveur `companyName ASC` par défaut.
|
||||
|
||||
## Écran « Ajouter un client »
|
||||
|
||||
Création par **onglets successifs avec validation incrémentale** : pour pouvoir passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant**, et les champs de l'onglet validé passent en lecture seule + bouton « Valider » désactivé (disabled).
|
||||
|
||||
### Formulaire principal (pré-onglets)
|
||||
|
||||
C'est le 1er bloc à remplir. Sans validation de ce formulaire, les onglets ne sont pas accessibles.
|
||||
|
||||
> **V1 — refonte-contact** : le contact principal (Nom / Prénom / Téléphone / Téléphone 2 / Email) a été **retiré** du formulaire principal. Les coordonnées se saisissent désormais dans l'onglet **Contacts** (RG-1.05 / RG-1.14). Le formulaire principal ne contient plus que Entreprise + Catégorie + relation Distributeur/Courtier.
|
||||
|
||||
| Champ | Type composant | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom du client (Entreprise)** | `<MalioInputText>` | Oui | RG-1.18 (normalisation UPPERCASE serveur) |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de l'API ; M2M Client ↔ Category |
|
||||
| **Distributeur / Courtier** | `<MalioSelect>` | Non | Valeurs : `Dépend du distributeur` / `Dépend du courtier` / `Aucun`. RG-1.03 conditionne les 2 champs suivants. |
|
||||
| **Nom du distributeur** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du distributeur ». Liste = clients ayant ≥ 1 catégorie de **code** `DISTRIBUTEUR` (ERP-78), via `GET /api/clients?categoryCode=DISTRIBUTEUR`. RG-1.03. |
|
||||
| **Nom du courtier** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du courtier ». Liste = clients ayant ≥ 1 catégorie de **code** `COURTIER` (ERP-78), via `GET /api/clients?categoryCode=COURTIER`. RG-1.03. |
|
||||
| **Prestation de triage** | `<MalioCheckbox>` | Non | — |
|
||||
|
||||
**Action** : « Valider » (`<MalioButton>`) → POST `/api/clients` ([`spec-back.md` § 4.3](./spec-back.md)). Si succès, on passe automatiquement à l'onglet « Information ».
|
||||
|
||||
### Onglet « Information »
|
||||
|
||||
Saisir les informations de l'entreprise.
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **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`).
|
||||
|
||||
### Onglet « Contact »
|
||||
|
||||
Saisir un ou plusieurs contacts associés au client. **(V1 — refonte-contact : plus de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)** Au moins un bloc Contact valide est requis (RG-1.14).
|
||||
|
||||
**Bloc Contact** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom** | `<MalioInputText>` | Conditionnel | RG-1.05 + RG-1.19 (Capitalize) |
|
||||
| **Prénom** | `<MalioInputText>` | Conditionnel | RG-1.05 + RG-1.19 (Capitalize) |
|
||||
| **Fonction** | `<MalioInputText>` | Non | — |
|
||||
| **Téléphone** (x1, +1 possible) | `<MalioInputText>` | Non | RG-1.20 (format) |
|
||||
| **Email** | `<MalioInputText>` type email | Non | RG-1.21 (lowercase) |
|
||||
|
||||
**RG-1.14 (renforcement validée par Tristan le 28/05)** : **au moins 1 bloc Contact valide** (au moins Nom OU Prénom rempli) est obligatoire pour valider l'onglet. Donc l'onglet Contact ne peut pas être finalisé vide.
|
||||
|
||||
**Actions** :
|
||||
- « + Nouveau contact » : ajoute un bloc. Bouton **désactivé tant que le bloc précédent n'a pas Prénom OU Nom rempli** (RG-1.05).
|
||||
- « Supprimer » (icône) sur un bloc : modal de confirmation (`<MalioButton>` Annuler / Confirmer). Si Oui → suppression du bloc.
|
||||
- « Valider » → PATCH `/api/clients/{id}/contacts` (création/mise à jour de la collection).
|
||||
|
||||
### Onglet « Adresse »
|
||||
|
||||
Saisir une ou plusieurs adresses du client, rattachées à un ou plusieurs sites Starseed (Châtellerault 86 / Saint-Jean 17 / Pommevic 82) et à des contacts.
|
||||
|
||||
**Bloc Adresse** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Prospect** | `<MalioCheckbox>` | Non | RG-1.06 — masque Adresse de livraison + Facturation si coché |
|
||||
| **Adresse de livraison** | `<MalioCheckbox>` | Non | RG-1.07 — masque Prospect si coché |
|
||||
| **Facturation** | `<MalioCheckbox>` | Non | RG-1.08 — masque Prospect si coché ; affiche le champ Email (RG-1.11) |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` **hors codes `DISTRIBUTEUR` / `COURTIER`** (ERP-78 — ces codes qualifient une relation entre clients, pas un lieu). Le front exclut ces 2 codes du select (le `code` est exposé en lecture sur `/api/categories`). |
|
||||
| **Pays** | `<MalioSelect>` | Oui | Préremplie « France » |
|
||||
| **Code postal** | `<MalioInputText>` (masque numérique) | Oui | RG-1.09 — déclenche autocomplete ville via BAN |
|
||||
| **Ville** | `<MalioSelect>` | Oui | RG-1.09 — alimentée par api-adresse.data.gouv.fr suivant le CP |
|
||||
| **Adresse** | `<MalioInputText>` (saisie assistée) | Oui | RG-1.09 — autocomplete BAN |
|
||||
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
|
||||
| **Sites Starseed** | `<MalioSelectCheckbox>` (multi-checkbox 86 / 17 / 82) | Oui | RG-1.10 — ≥ 1 site obligatoire |
|
||||
| **Contact(s) rattaché(s)** | `<MalioSelectCheckbox>` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
|
||||
| **Email (facturation)** | `<MalioInputText>` type email | Conditionnel | RG-1.11 — visible/obligatoire uniquement si « Facturation » coché |
|
||||
|
||||
**Actions** :
|
||||
- « + Nouvelle Adresse » : ajoute un bloc identique.
|
||||
- « Supprimer » : modal de confirmation puis suppression.
|
||||
- « Valider » → PATCH `/api/clients/{id}/addresses`.
|
||||
|
||||
### Onglet « Transport »
|
||||
|
||||
🚧 **Placeholder blanc au M1.** Frame vide. Aucun champ. Aucun bouton de validation. L'utilisateur passe automatiquement à l'onglet suivant. **Pas de mention « En cours »** — c'est juste blanc (décision Tristan 28/05).
|
||||
|
||||
### Onglet « Comptabilité »
|
||||
|
||||
⚠ **Accessible aux rôles avec `commercial.clients.accounting.manage`** (Admin + Compta au M1). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer cet onglet** (champs SIREN / N° compte / TVA / Délai / Type de règlement / Banque / RIBs) — cf. décision Q1, aligné docx. Compta ne peut pas créer un client (pas de `manage` général).
|
||||
|
||||
**Champs comptables** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **SIREN** | `<MalioInputText>` (masque 9 chiffres) | Oui | Format 9 chiffres. **Pas d'unicité** (décision Q4) |
|
||||
| **Numéro de compte** | `<MalioInputText>` | Oui | — |
|
||||
| **Mode de TVA** | `<MalioSelect>` | Oui | Liste depuis `/api/tva_modes` |
|
||||
| **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-1.12 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks`. |
|
||||
|
||||
**Bloc RIB** (0..n blocs, présence obligatoire conditionnée par RG-1.13) :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Libellé** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 |
|
||||
| **BIC** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 — `#[AuditIgnore]` (champ sensible) |
|
||||
| **IBAN** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 — `#[AuditIgnore]` (champ sensible) |
|
||||
|
||||
**Actions** :
|
||||
- « + RIB » : ajoute un bloc.
|
||||
- « Supprimer » (icône) : modal de confirmation.
|
||||
- « Valider » → PATCH `/api/clients/{id}/accounting`.
|
||||
|
||||
### Onglets « Statistiques » / « Rapports » / « Échanges »
|
||||
|
||||
🚧 **Placeholders blancs au M1.** Mêmes règles que Transport (frames vides, pas de validation).
|
||||
|
||||
## Écran « Consultation client »
|
||||
|
||||
Tous les champs en **lecture seule**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans bouton `+` pour ajouter des blocs Contact / Adresse / RIB.
|
||||
|
||||
- **Flèche retour** (à gauche) → revient au Répertoire.
|
||||
- **Bouton « Modifier »** (à droite, visible si l'utilisateur a la permission `commercial.clients.manage`) → bascule sur l'écran Modification.
|
||||
- **Bouton « Archiver »** (à droite, visible **uniquement pour Admin** via permission `commercial.clients.archive`) → ouvre une modal de confirmation, puis PATCH `/api/clients/{id}` `{ "isArchived": true }`. Le client passe en archivé (cf. flag `is_archived`).
|
||||
|
||||
> Le client archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé. Décision validée Tristan 28/05.
|
||||
|
||||
### Onglets affichés en consultation
|
||||
|
||||
Mêmes onglets qu'en création, **plus** les 4 placeholders blancs. L'utilisateur navigue librement entre les onglets (pas de séquence forcée en consultation).
|
||||
|
||||
## Écran « Modification client »
|
||||
|
||||
Comportement identique à l'écran Ajouter sauf :
|
||||
- **Pas de formulaire principal** (les champs principaux sont édités via les onglets correspondants).
|
||||
- Les champs sont **pré-remplis** avec les valeurs actuelles.
|
||||
- **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` restent en lecture seule (pas de bouton Valider, pas d'icône suppression de bloc).
|
||||
- Les onglets placeholders restent inaccessibles à l'édition (blancs).
|
||||
|
||||
## Composants UI à utiliser (`@malio/layer-ui`)
|
||||
|
||||
- **Datatable** : `<MalioDataTable>` (Répertoire)
|
||||
- **Input texte** : `<MalioInputText>`
|
||||
- **Input numérique** : `<MalioInputNumber>`
|
||||
- **Input montant** : `<MalioInputAmount>` (CA, Résultat)
|
||||
- **TextArea** : `<MalioInputTextArea>` (Description)
|
||||
- **Select simple** : `<MalioSelect>` (Pays, Ville, distributeur/courtier, refs comptables)
|
||||
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (Catégorie, Sites, Contacts rattachés)
|
||||
- **Checkbox** : `<MalioCheckbox>` (Prospect, Adresse livraison, Facturation, Prestation de triage)
|
||||
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
|
||||
- **Toasts** : standards via `useApi()`
|
||||
|
||||
**Exceptions autorisées** (à commenter `// TODO migrer quand Malio couvre`) :
|
||||
- `<input type="date">` pour « Date de création » (composant `MalioDate` non couvert)
|
||||
- Modal de confirmation : composant à confirmer côté équipe front (probablement `<MalioModal>` ou un wrapper à créer dans `frontend/shared/`)
|
||||
|
||||
## Règles de formatage et normalisation
|
||||
|
||||
Le serveur normalise systématiquement (cf. RG-1.18 à RG-1.21 dans [`spec-back.md`](./spec-back.md)) :
|
||||
|
||||
| Champ | Normalisation serveur | Affichage front |
|
||||
|---|---|---|
|
||||
| Nom entreprise (`companyName`) | UPPERCASE intégral | UPPERCASE |
|
||||
| Nom + Prénom contact | Capitalize (1ère lettre majuscule + reste minuscule) | identique |
|
||||
| Téléphone (téléphones des blocs `ClientContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` à l'affichage (filter Vue) |
|
||||
| Email | lowercase intégral | identique |
|
||||
|
||||
> **Le front ne fait pas la normalisation** — il envoie la valeur saisie, le serveur normalise puis renvoie la valeur normalisée. L'UI affiche immédiatement la valeur normalisée renvoyée par l'API. Cohérent avec le pattern `useApi()`.
|
||||
|
||||
## API adresse postale
|
||||
|
||||
Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse.data.gouv.fr** (Base Adresse Nationale, gratuite, française).
|
||||
|
||||
- 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}` (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)
|
||||
|
||||
| # | Zone d'ombre V0 | Résolution (cf. `spec-back.md`) |
|
||||
|---|---|---|
|
||||
| 1 | Catégorie en multi-select non clarifiée (1 ou n par client) | **M2M `client_category`** validée. Refonte ERP-78 : type unique `CLIENT` ; `Distributeur`/`Courtier`/`Secteur`/`Autre` (+ catégories métier) sont des `Category` portant un `code` stable (HP-3 du M0 levé). |
|
||||
| 2 | Distributeur / Courtier : liste de quoi ? | **Auto-référence Client** via 2 FK nullables `distributor_id` et `broker_id` (cf. RG-1.03). Une seule des deux est remplie à la fois. |
|
||||
| 3 | Onglet « Comptabilité » : qui édite ? | **Admin et Compta** peuvent éditer l'onglet Comptabilité (`commercial.clients.accounting.manage`). Bureau / Commerciale ne voient pas l'onglet. Compta ne peut pas créer un client (pas de `manage` global), mais peut éditer la partie comptable d'un client existant. |
|
||||
| 4 | Workflow par onglet | **Sauvegarde incrémentale**. POST formulaire principal crée le `Client` (status implicite « actif »). Chaque onglet validé = PATCH partiel par groupe de sérialisation dédié. Pas d'état « draft ». |
|
||||
| 5 | Onglets « À venir » | **Placeholders blancs** (frames vides, pas de message). Ré-activables sans rebuild quand les modules associés arriveront. |
|
||||
| 6 | Archive vs soft delete | **Flag `is_archived` séparé de `deleted_at`**. Archive ≠ delete : un client archivé est masqué par défaut mais reste en BDD éditable (Admin seul). Filtres UI distincts. Soft delete = HP M2. |
|
||||
| 7 | Unicité métier | **Nom d'entreprise uniquement** (case-insensitive, parmi non-archivés) — décision Q4. SIREN et email NON uniques. Index partiel Postgres `uq_client_company_name_active`. Doublon de nom → 409 Conflict. |
|
||||
| 8 | Téléphones (max 2) | Sur les blocs `ClientContact` (`phone_primary` + `phone_secondary`). _(V1 : retirés du Client — refonte-contact.)_ |
|
||||
| 9 | API code postal | **api-adresse.data.gouv.fr** (BAN). Appel direct front via composable dédié. Cas dégradé : saisie libre + toast. |
|
||||
| 10 | Référentiels comptables | **4 entités CRUD-ables** (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`) seedées au M1, CRUD admin futur (HP-M2). |
|
||||
| 11 | Format de l'export | **XLSX uniquement** au M1. CSV à étudier en HP. |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tickets Lesstime générés
|
||||
|
||||
**TaskGroup Lesstime** : à créer — `M1 — Répertoire clients` (projet `ERP / Starseed`, projectId=6).
|
||||
|
||||
> Détail complet, table des tickets et action manuelle dans Lesstime → voir [`spec-back.md § Tickets Lesstime générés`](./spec-back.md#-tickets-lesstime-générés).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,331 +0,0 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M2
|
||||
nom: "Répertoire fournisseurs"
|
||||
ecran: repertoire-fournisseurs
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0.2
|
||||
date_redaction: 2026-06-02
|
||||
# Historique : V0.2 (2026-06-03) — Refonte contact : suppression du bloc contact principal inline
|
||||
# du formulaire Supplier (Nom/Prénom/Téléphone/Téléphone 2/Email). Saisie via l'onglet Contacts.
|
||||
# Aligné sur M1. Cf. docs/specs/M1-clients/refonte-contact/README.md
|
||||
|
||||
# === LIENS ===
|
||||
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-36987&p=f&m=dev"
|
||||
regles_metier: [RG-2.01, RG-2.02, RG-2.03, RG-2.04, RG-2.05, RG-2.06, RG-2.07, RG-2.08, RG-2.09, RG-2.10, RG-2.11, RG-2.12, RG-2.13, RG-2.14, RG-2.15, RG-2.16, RG-2.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)"
|
||||
resume: "Module 2 — Répertoire fournisseurs. Page d'entrée Commercial. Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Information / Contact / Adresse / Comptabilité (Transport, Statistiques, Rapports, Échanges = placeholders 'À venir')."
|
||||
trace_archivee: "uploads/M2-reportoire-fournisseurs.docx (V0.1) + M2-reportoire-fournisseurs-V01.pdf"
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_taskgroup_id: 26
|
||||
lesstime_project_id: 6
|
||||
statut_global: a_dev
|
||||
---
|
||||
|
||||
# Module 2 — Répertoire fournisseurs (V0.1 front)
|
||||
|
||||
> **Origine** : spec front livrée le 22/05/2026 (V0), amendée le 01/06/2026 (V0.1) — `M2-reportoire-fournisseurs.docx`. Restitution Markdown pour intégration au workflow MALIO. Le contenu fonctionnel original n'est pas modifié ; toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M2 réutilise massivement le pattern et les composants posés au [M1 clients](../M1-clients/spec-front.md).
|
||||
|
||||
## But
|
||||
|
||||
Permettre aux utilisateurs Starseed (selon rôle) de gérer le **répertoire des fournisseurs** de l'organisation : consultation, création, modification, archivage. C'est la **deuxième porte d'entrée du module Commercial** (aux côtés des Clients).
|
||||
|
||||
## Accès
|
||||
|
||||
- **Depuis** : menu principal → section **Commercial** → entrée « Répertoire fournisseurs » (route `/suppliers`).
|
||||
- **Rôles autorisés** :
|
||||
|
||||
| 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** | ❌ (pas d'accès) | ❌ | ❌ |
|
||||
|
||||
> **Note** : RBAC identique au M1, transposée sur `commercial.suppliers.*`. Compta édite uniquement l'onglet Comptabilité (SIREN / N° compte / TVA / Délai / Type de règlement / Banque / RIBs) d'un fournisseur existant ; Compta ne peut pas **créer** un fournisseur. **L'archivage est réservé à Admin** (cf. tableau du docx).
|
||||
|
||||
## Navigation
|
||||
|
||||
Page d'entrée du module **Commercial** (route `/suppliers`). Titre : « **Répertoire fournisseurs** ».
|
||||
|
||||
- Affichage principal : un **datatable** listant tous les fournisseurs **actifs** (les archivés sont masqués par défaut — toggle UI dédié).
|
||||
- **Clic sur une ligne** → écran **Consultation fournisseur** (page dédiée).
|
||||
- **Bouton « + Ajouter »** (haut droite) → écran **Ajouter un fournisseur**.
|
||||
- **Bouton « Filtrer »** (haut droite, **à côté de « + Ajouter »**) → ouvre le **panneau de filtres** (cf. ci-dessous). Un badge/compteur indique le nombre de filtres actifs ; un bouton « Réinitialiser » les vide.
|
||||
- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des fournisseurs **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md).
|
||||
|
||||
### Panneau de filtres (bouton « Filtrer »)
|
||||
|
||||
Ouvre un drawer/popover (composant à confirmer côté équipe front — réutiliser le pattern M1 s'il existe). Filtres proposés, branchés sur les query params de `GET /api/suppliers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) :
|
||||
|
||||
| Filtre | Composant | Query param back |
|
||||
|---|---|---|
|
||||
| **Recherche** (nom entreprise / contact / email — recherche contact via `supplier_contact`, décision D1) | `<MalioInputText>` | `?search=` |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi, type FOURNISSEUR) | `?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/suppliers` avec les params.
|
||||
- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6). Le bouton « Filtrer » + son panneau remplacent/regroupent l'ancien toggle « archivés » isolé.
|
||||
|
||||
## Datatable du Répertoire
|
||||
|
||||
Composant : `<MalioDataTable>` branché sur `usePaginatedList<Supplier>({ url: '/suppliers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes **conformes à la maquette Figma** (4 colonnes) :
|
||||
|
||||
| Colonne | Source | Tri |
|
||||
|---|---|---|
|
||||
| **Nom** | `supplier.companyName` | ASC par défaut |
|
||||
| **Catégories** | `supplier.categories[].name` (embarquées en liste — cohérence M1/ERP-62 ; libellé = `name`, pas `label`) | Non |
|
||||
| **Site** | `supplier.sites[].name` (agrégat des adresses via `getSites()` ; `Site` n'a pas de `code`) | Non |
|
||||
| **Dernière activité** | `supplier.updatedAt` (format `JJ-MM-AAAA`) — exposé dans `supplier:read` | Oui |
|
||||
|
||||
> **Clic sur une ligne** (texte en bleu lien) → écran Consultation.
|
||||
> **Filtres** : regroupés dans le panneau du bouton « Filtrer » (cf. section précédente), dont l'inclusion des archivés (désactivée par défaut). **État local** (jamais dans l'URL — règle ABSOLUE n°6).
|
||||
> **Pagination** : `<MalioDataTable>` + `usePaginatedList`, options **standard Starseed 10 / 25 / 50 (défaut 10)** — on **n'applique pas** le « Ligne : 20 » de la maquette (décision Matthieu : on reste sur le standard). Tri serveur `companyName ASC` par défaut.
|
||||
|
||||
## Écran « Ajouter un fournisseur »
|
||||
|
||||
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).
|
||||
|
||||
**Barre d'onglets en création (5 onglets, conforme maquette)** : `Information` · `Contacts` · `Adresses` · `Transport` · `Comptabilité`. L'onglet `Information` est actif par défaut juste après validation du formulaire principal. Les onglets `Statistiques`, `Rapports` et `Échanges` **n'apparaissent PAS dans le flux de création** — ils ne sont présents qu'en Consultation / Modification.
|
||||
|
||||
### Formulaire principal (pré-onglets)
|
||||
|
||||
1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/suppliers`, puis bascule sur l'onglet Information ; les champs passent en readonly.
|
||||
|
||||
> **V0.2 — refonte-contact** : le contact principal (Nom / Prénom / Téléphone / Téléphone 2 / Email) a été **retiré** du formulaire principal. Les coordonnées se saisissent dans l'onglet **Contacts** (RG-2.04 / RG-2.13). Le formulaire principal ne contient plus que Entreprise + Catégorie.
|
||||
|
||||
| Champ | Type composant | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom du fournisseur (Entreprise)** | `<MalioInputText>` | Oui | RG-2.12 (UPPERCASE serveur) |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | `Category` de **type FOURNISSEUR** via `GET /api/categories?typeCode=FOURNISSEUR` (RG-2.10). Libellé affiché = `category.name`. ⚠️ Le type + le filtre `?typeCode=` sont **à créer** côté back (n'existent pas en prod — cf. spec-back § 2.4). |
|
||||
|
||||
**Action** : « Valider » (`<MalioButton>`) → POST `/api/suppliers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Information ».
|
||||
|
||||
### Onglet « Information »
|
||||
|
||||
Saisir les informations du fournisseur.
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Description** | `<MalioInputTextArea>` | Conditionnel | RG-2.03 (obligatoire rôle Commerciale) |
|
||||
| **Concurrent** | `<MalioInputText>` | Conditionnel | RG-2.03 |
|
||||
| **Date création** (entreprise) | `<input type="date">` (exception Malio — `// TODO migrer`) | Conditionnel | RG-2.03 |
|
||||
| **Nombre de salariés** | `<MalioInputNumber>` | Conditionnel | RG-2.03 |
|
||||
| **CA €** | `<MalioInputAmount>` | Conditionnel | RG-2.03 |
|
||||
| **Dirigeant** | `<MalioInputText>` | Conditionnel | RG-2.03 |
|
||||
| **Résultat €** | `<MalioInputAmount>` | Conditionnel | RG-2.03 |
|
||||
| **Volume Prévisionnel** | `<MalioInputNumber>` | Conditionnel | RG-2.03 (champ spécifique fournisseur) |
|
||||
|
||||
> **Disposition maquette** : 3 colonnes — ligne 1 (Description / Concurrent / Date création), ligne 2 (Nombre de salariés / CA / Dirigeant), ligne 3 (Résultat / Volume Prévisionnel).
|
||||
|
||||
**Action** : « Valider » → PATCH `/api/suppliers/{id}` (groupe `supplier:write:information`).
|
||||
|
||||
### Onglet « Contact »
|
||||
|
||||
Saisir un ou plusieurs contacts. **(V0.2 — refonte-contact : plus de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)** Au moins un bloc Contact valide est requis (RG-2.13).
|
||||
|
||||
**Bloc Contact** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom** | `<MalioInputText>` | Conditionnel | RG-2.04 + RG-2.12 (Capitalize) |
|
||||
| **Prénom** | `<MalioInputText>` | Conditionnel | RG-2.04 + RG-2.12 (Capitalize) |
|
||||
| **Fonction** | `<MalioInputText>` | Non | — |
|
||||
| **Téléphone** (x1, +1 possible) | `<MalioInputText>` | Non | RG-2.12 (format) |
|
||||
| **Email** | `<MalioInputText>` type email | Non | RG-2.12 (lowercase) |
|
||||
|
||||
**RG-2.04 / RG-2.13** : au moins 1 bloc Contact valide (Nom OU Prénom rempli) pour valider l'onglet — l'onglet Contact ne peut pas être finalisé vide.
|
||||
|
||||
**Actions** :
|
||||
- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a pas Prénom OU Nom** (RG-2.04).
|
||||
- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc.
|
||||
- « Valider » → PATCH `/api/suppliers/{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 |
|
||||
|---|---|---|---|
|
||||
| **Type d'adresse** | `<MalioRadioButton>` — `Prospect` / `Départ` / `Rendu` | Oui | RG-2.09 (exclusif, enum `PROSPECT`/`DEPART`/`RENDU`) |
|
||||
| **Pays** | `<MalioSelect>` (saisie assistée — préremplie « France ») | Oui | — |
|
||||
| **Code postal** | `<MalioInputText>` (saisie assistée) | Oui | RG-2.05 — déclenche autocomplete ville (BAN) |
|
||||
| **Ville** | `<MalioSelect>` (saisie assistée) | Oui | RG-2.05 — alimentée par api-adresse.data.gouv.fr suivant le CP |
|
||||
| **Adresse** | `<MalioInputText>` (saisie assistée) | Oui | RG-2.05 — autocomplete BAN |
|
||||
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
|
||||
| **Sélecteur de site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | Oui | RG-2.06 — ≥ 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). |
|
||||
| **Catégories** | `<MalioSelectCheckbox>` (multi) | Oui | Catégories de type FOURNISSEUR (RG-2.10), liées aux catégories du fournisseur |
|
||||
| **Contact** | `<MalioSelectCheckbox>` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
|
||||
| **Benne(s)** | `<MalioInputNumber>` (stepper −/+ , défaut 0) | Non | Champ spécifique fournisseur |
|
||||
| **Prestation de triage** | `<MalioCheckbox>` | Non | Champ spécifique fournisseur (porté par l'adresse — colonne back `triage_provider`) |
|
||||
|
||||
> **Disposition maquette par bloc** : ligne 1 = radio (Prospect / Départ / Rendu) + Pays + Code postal ; ligne 2 = Ville + Adresse + Adresse complémentaire ; ligne 3 = sites (86 / 17 / 82) + Catégories + Contact ; ligne 4 = Benne(s) + Prestation de triage. Icône corbeille en haut à droite de chaque bloc pour le supprimer.
|
||||
|
||||
**Actions** :
|
||||
- « + Nouvelle Adresse » : ajoute un bloc identique au premier.
|
||||
- « Supprimer » : modal de confirmation puis suppression.
|
||||
- « Valider » → PATCH `/api/suppliers/{id}/addresses`.
|
||||
|
||||
### Onglet « Transport »
|
||||
|
||||
🚧 **Onglet placeholder minimal au M2.** Conforme à la maquette : la frame est **vide** (aucun champ, aucun bouton de validation, aucune API back). L'onglet reste navigable. Un libellé discret « À venir » est toléré mais non requis (la maquette ne l'affiche pas). Cet onglet **fait partie de la barre de création** (entre Adresses et Comptabilité).
|
||||
|
||||
### Onglet « Comptabilité »
|
||||
|
||||
⚠ **Accessible aux rôles avec `commercial.suppliers.accounting.view`** (Admin + Compta au M2). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer** cet onglet (`accounting.manage`). Compta ne peut pas créer un fournisseur (pas de `manage` global).
|
||||
|
||||
**Champs comptables** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **SIREN** | `<MalioInputText>` (masque 9 chiffres) | Oui | 9 chiffres. **Pas d'unicité** (cf. § 2.6) |
|
||||
| **Numéro de compte** | `<MalioInputText>` | Oui | — |
|
||||
| **Mode de TVA** | `<MalioSelect>` | Oui | Liste depuis `/api/tva_modes` (référentiel 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-2.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-2.08) :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Libellé** | `<MalioInputText>` | Oui (si LCR) | RG-2.08 |
|
||||
| **BIC** | `<MalioInputText>` | Oui (si LCR) | RG-2.08 |
|
||||
| **IBAN** | `<MalioInputText>` | Oui (si LCR) | RG-2.08 |
|
||||
|
||||
**Actions** :
|
||||
- « + RIB » : ajoute un bloc.
|
||||
- « Supprimer » (icône) : modal de confirmation.
|
||||
- « Valider » → PATCH `/api/suppliers/{id}` (groupe `supplier:write:accounting`) + sous-ressource RIBs.
|
||||
|
||||
### Onglets « Statistiques » / « Rapports » / « Échanges »
|
||||
|
||||
🚧 **Placeholders minimaux au M2 — uniquement en Consultation / Modification** (ils n'apparaissent **pas** dans le flux de création, cf. maquette). Frames vides, pas de validation, pas d'API.
|
||||
|
||||
## Écran « Consultation fournisseur »
|
||||
|
||||
Tous les champs en **lecture seule**. 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 `commercial.suppliers.manage`) → écran Modification.
|
||||
- **Bouton « Archiver »** (droite, visible **uniquement Admin** via `commercial.suppliers.archive`) → modal de confirmation, puis PATCH `/api/suppliers/{id}` `{ "isArchived": true }`.
|
||||
|
||||
> Un fournisseur archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé.
|
||||
|
||||
### Onglets affichés en consultation
|
||||
|
||||
Information / Contacts / Adresses / Transport / Statistiques / Rapports / Échanges / Comptabilité (les 4 derniers métiers en placeholder « À venir », Comptabilité selon permission). L'utilisateur navigue **librement** entre les onglets (pas de séquence forcée en consultation).
|
||||
|
||||
## Écran « Modification fournisseur »
|
||||
|
||||
Comportement identique à l'écran Ajouter sauf :
|
||||
- **Pas de formulaire principal** réaffiché (champs principaux édités via les onglets correspondants).
|
||||
- Les champs sont **pré-remplis** avec les valeurs actuelles.
|
||||
- **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).
|
||||
- Les onglets placeholders « À venir » restent non éditables.
|
||||
|
||||
## Composants UI à utiliser (`@malio/layer-ui`)
|
||||
|
||||
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
|
||||
- **Input texte** : `<MalioInputText>`
|
||||
- **Input numérique** : `<MalioInputNumber>` (Nombre de salariés, Volume prévisionnel, Bennes)
|
||||
- **Input montant** : `<MalioInputAmount>` (CA, Résultat)
|
||||
- **TextArea** : `<MalioInputTextArea>` (Description)
|
||||
- **Select simple** : `<MalioSelect>` (Pays, Ville, référentiels comptables)
|
||||
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (Catégorie, Sites, Contacts rattachés)
|
||||
- **Radio** : `<MalioRadioButton>` (Type d'adresse Prospect / Départ / Rendu — RG-2.09)
|
||||
- **Checkbox** : `<MalioCheckbox>` (Prestataire de triage)
|
||||
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
|
||||
- **Toasts** : standards via `useApi()`
|
||||
|
||||
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
|
||||
- `<input type="date">` pour « Date Création » (`MalioDate` non couvert).
|
||||
- Modal de confirmation : `<MalioModal>` ou wrapper partagé dans `frontend/shared/` (réutiliser celui du M1 si présent).
|
||||
|
||||
## Composables & appels API
|
||||
|
||||
- `usePaginatedList<Supplier>({ url: '/suppliers' })` — liste paginée (obligatoire, règle frontend). La liste consomme `categories[]` (libellé = `name`) et `sites[]` (libellé = `name`, pas de `code`) **embarqués** + `updatedAt` (cohérence M1/ERP-62, cf. [`spec-back.md § 2.12 / § 4.0`](./spec-back.md)). Côté back, fetch-joins anti-N+1.
|
||||
- `useSupplier(id)` — charge le détail via `GET /api/suppliers/{id}`, qui **embarque** `contacts`, `addresses` (avec `sites` / `categories` / `contacts` imbriqués) et, si permission, `ribs` + scalaires compta. Les écrans Consultation et Modification se peuplent depuis cette seule réponse (RETEX M1 §2 : embed borné, pas de N+1 d'appels). **DoD avant intégration** : vérifier que le JSON réel contient bien ces blocs (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)).
|
||||
- `useSupplierForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useClientForm()`.
|
||||
- `useAddressAutocomplete()` — **réutilisé du M1** (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é du M1** pour l'affichage `XX XX XX XX XX`.
|
||||
|
||||
## Règles de formatage et normalisation
|
||||
|
||||
Le serveur normalise systématiquement (RG-2.12 — cf. [`spec-back.md`](./spec-back.md)) :
|
||||
|
||||
| Champ | Normalisation serveur | Affichage front |
|
||||
|---|---|---|
|
||||
| Nom fournisseur (`companyName`) | UPPERCASE intégral | UPPERCASE |
|
||||
| Nom + Prénom contact | Capitalize | identique |
|
||||
| Téléphones (blocs `SupplierContact`) | 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. Cohérent avec `useApi()`.
|
||||
|
||||
## 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** (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.
|
||||
- À 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 M1 (clients)
|
||||
|
||||
| Zone | M1 clients | M2 fournisseurs |
|
||||
|---|---|---|
|
||||
| Distributeur / Courtier | Auto-référence Client (RG-1.03) | **Absent** |
|
||||
| Prestation de triage | Booléen sur le client (formulaire principal) | **Booléen sur l'adresse** (`triage_provider`) |
|
||||
| Type d'adresse | 3 checkboxes Prospect / Livraison / Facturation | **Radio exclusif** Prospect / Départ / Rendu (RG-2.09) |
|
||||
| Email facturation sur adresse | Oui (conditionnel) | **Absent** |
|
||||
| Champ adresse « Bennes » | — | **Présent** (nombre) |
|
||||
| Onglet Information | 7 champs | **8 champs** (ajout « Volume prévisionnel ») |
|
||||
| Catégories | type unique `CLIENT` (codes ERP-78) | **nouveau type `FOURNISSEUR`** |
|
||||
| Archivage | Admin | **Admin uniquement** (idem) |
|
||||
| Onglets « À venir » | frames blanches | **placeholder « À venir »** (minimal) |
|
||||
|
||||
## Points résolus côté back
|
||||
|
||||
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|
||||
|---|---|---|
|
||||
| 1 | Catégorie multi-select | M2M `supplier_category`, `Category` de type **FOURNISSEUR** (RG-2.10) |
|
||||
| 2 | Type d'adresse Prospect/Départ/Rendu | Enum exclusif `address_type` (RG-2.09) |
|
||||
| 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 » (Transport / Stats / 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 fournisseur uniquement (à valider — § 2.6). SIREN/email non uniques |
|
||||
| 8 | Référentiels comptables | Réutilisés du M1 (zéro duplication) |
|
||||
| 9 | API code postal | BAN via `useAddressAutocomplete()` du M1 |
|
||||
| 10 | Format export | XLSX uniquement (CSV = HP) |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tickets Lesstime
|
||||
|
||||
**TaskGroup Lesstime** : à créer — `M2 — Répertoire fournisseurs` (projet `ERP / Starseed`, projectId=6).
|
||||
|
||||
> Détail complet et action manuelle → voir [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper).
|
||||
@@ -1,322 +0,0 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M6
|
||||
nom: "Tournées commerciales terrain"
|
||||
ecran: tournees-terrain
|
||||
owner_spec: Matthieu
|
||||
backup_spec: ""
|
||||
version: V0.2
|
||||
# Historique :
|
||||
# V0.2 (2026-06-11) — RÉDUCTION DE SCOPE : suppression du volet « rapport de visite »
|
||||
# (entité VisitReport, fichiers, offres de prix, note /5, saisie vocale, historique des
|
||||
# visites) et du mode terrain mobile dédié. Périmètre recentré sur : géolocalisation,
|
||||
# carte interactive, planification de tournées, et onglet « Carte » dans les fiches Tiers.
|
||||
# V0.1 (2026-06-11) — Rédaction initiale (inspirée de Badger Maps, SPOTIO, Portatour, Nomadia).
|
||||
date_redaction: 2026-06-11
|
||||
|
||||
# === LIENS ===
|
||||
spec_front: ./spec-front.md
|
||||
maquette_figma: ""
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_taskgroup_id: 28 # M6 — Tournées commerciales terrain (projet STARSEED #6)
|
||||
lesstime_project_id: 6
|
||||
statut_global: en_dev
|
||||
|
||||
# === DÉPENDANCES AMONT ===
|
||||
depend_de:
|
||||
- M1-clients # Client / ClientAddress (cible de visite + onglet Carte)
|
||||
- M2-suppliers # Supplier / SupplierAddress (cible de visite + onglet Carte)
|
||||
- Sites # rattachement site d'une adresse (déjà en place)
|
||||
- Core # User (commercial), Role, Permission, JWT
|
||||
- Shared # TimestampableBlamableTrait + contrats inter-modules
|
||||
---
|
||||
|
||||
# Spec — Module 6 : Tournées commerciales terrain (`field_sales`)
|
||||
|
||||
> **Périmètre V0.2 (réduit)** : géolocalisation des adresses Tiers, carte interactive, planification
|
||||
> de tournées (étapes, optimisation, navigation Waze/Maps, feuille de route PDF) et onglet « Carte »
|
||||
> dans les fiches Client/Fournisseur. **Hors scope : tout rapport de visite** (compte-rendu, note,
|
||||
> offres de prix, fichiers, saisie vocale) et le mode terrain mobile dédié.
|
||||
|
||||
## 1. Contexte & objectif
|
||||
|
||||
Donner aux commerciaux terrain (technico-commerciaux agricoles : visites d'exploitations, coopératives,
|
||||
négoces) un outil de **planification de tournées** intégré à Starseed, reposant sur le référentiel Tiers
|
||||
existant (Clients M1 + Fournisseurs M2). Fonctionne sur **desktop et mobile/tablette** (responsive, pas
|
||||
d'offline en V1).
|
||||
|
||||
Le commercial doit pouvoir :
|
||||
|
||||
1. Voir ses Tiers sur une **carte interactive** (pins colorés par type : client / fournisseur / prospect / custom).
|
||||
2. **Construire une tournée lui-même** : ajouter des étapes (une étape = une adresse précise d'un Tiers ou un
|
||||
point libre), les **réordonner en drag & drop**, fixer une **heure de départ**.
|
||||
3. Obtenir le **temps total** et le **temps entre chaque étape** (calcul auto), avec heure d'arrivée estimée.
|
||||
4. Cliquer **« Trajet logique »** (V1, heuristique gratuite) puis **« Optimiser »** (V2, routier réel) pour
|
||||
ordonner les étapes au mieux.
|
||||
5. **Lancer la navigation** (Waze / Google Maps / Plan) vers une étape en un tap.
|
||||
6. **Dupliquer** une tournée et **exporter une feuille de route PDF**.
|
||||
7. Consulter un **onglet « Carte »** dans la fiche Client/Fournisseur affichant les adresses géolocalisées du Tiers.
|
||||
|
||||
## 2. Inspiration — logiciels de tournée de référence
|
||||
|
||||
| Logiciel | Pattern repris | Application Starseed |
|
||||
|---|---|---|
|
||||
| **Badger Maps** | *Lasso tool* : on entoure des Tiers sur la carte → route optimisée auto. Pins colorés par type. | Sélection lasso/rectangle sur la carte pour bâtir la tournée. Pins par type. |
|
||||
| **SPOTIO** | Réordonnancement **drag & drop**, lieu de départ + bouton *Optimize*. | Liste d'étapes draggable, point de départ paramétrable, boutons « Trajet logique » / « Optimiser ». |
|
||||
| **Portatour** | Optimisation en quelques secondes, temps entre RDV. | Durée de visite paramétrable par étape, intégrée au temps total. |
|
||||
| **Nomadia Field Sales** | Carte + tournée dans un outil mobile responsive. | Écran de planification responsive desktop + mobile. |
|
||||
|
||||
## 3. Décisions d'architecture
|
||||
|
||||
### 3.1 Nouveau module `field_sales`
|
||||
|
||||
La tournée est **transverse** : elle vise aussi bien des Clients (M1) que des Fournisseurs (M2). Module dédié
|
||||
`src/Module/FieldSales/` (ID `field_sales`, label « Tournées »), `REQUIRED = false` (activable via
|
||||
`config/modules.php`).
|
||||
|
||||
**Règle ABSOLUE n°1 respectée** : `FieldSales` n'importe **aucune** classe de `Commercial`. Il référence les
|
||||
Tiers visités via un **contrat partagé** `App\Shared\Domain\Contract\VisitableInterface` (`getId()`,
|
||||
`getDisplayName()`, `getVisitableType()` = `client|supplier`) résolu par `resolve_target_entities`, comme
|
||||
`ClientAddress` référence `SiteInterface` / `CategoryInterface`.
|
||||
|
||||
### 3.1.bis Une étape vise tout Tiers — et même un point libre
|
||||
|
||||
Une étape n'est pas limitée à Client/Fournisseur : elle vise **tout type de Tiers** (Client, Fournisseur,
|
||||
**Prestataire** à venir) via `VisitableInterface` (extensible sans toucher au module FieldSales), **ou un point
|
||||
`custom`** (prospect/RDV sans fiche : libellé + adresse + coordonnées saisis à la main). L'enum `tier_type` est
|
||||
volontairement **ouvert** (string + Assert\Choice = types Visitable enregistrés + `custom`).
|
||||
|
||||
### 3.2 Géolocalisation portée par l'adresse Tiers — **FAIT (ticket M6.1 / ERP-122)**
|
||||
|
||||
`latitude` / `longitude` / `geo_manual` / `geocoded_at` sur `client_address` et `supplier_address`, géocodage
|
||||
api-adresse.data.gouv.fr + pin ajustable. Prérequis routage : une étape sans coordonnées reste utilisable mais
|
||||
**exclue du calcul de trajet** (badge « à géolocaliser »).
|
||||
|
||||
### 3.3 Carte interactive — Leaflet + OpenStreetMap (pas Google Maps JS)
|
||||
|
||||
Pour l'**affichage** carte/pins : **Leaflet** + tuiles **OpenStreetMap** (ou IGN). Gratuit, RGPD-friendly, pas
|
||||
de clé facturée pour le rendu. Composant carte encapsulé dans `frontend/modules/field-sales/` ; côté
|
||||
formulaire/filtre on reste sur les composants `Malio*`. La carte est une **exception documentée** à
|
||||
`@malio/layer-ui` (type non couvert). Le **routing réel** (matrice de temps) est un service distinct (§ 3.4).
|
||||
|
||||
### 3.4 Stratégie de calcul de trajet — phasée
|
||||
|
||||
| Phase | Bouton | Moteur | Coût |
|
||||
|---|---|---|---|
|
||||
| **V1** | « Trajet logique » | Heuristique maison **plus proche voisin** (Haversine), départ fixé. Temps estimé = distance × vitesse moyenne paramétrable. | 0 € |
|
||||
| **V2** | « Optimiser » | **Matrix API** (temps routiers réels) + optimisation TSP (OpenRouteService / OSRM / Mapbox). | Par appel (cache, debounce) |
|
||||
|
||||
Contrat `RouteEngineInterface` (`computeMatrix`, `optimizeOrder`, `estimateLegDurations`) posé dès la V1 avec
|
||||
`HaversineRouteEngine`. La V2 ajoute `OrsRouteEngine` sans toucher au front. **On n'écrit jamais l'algo routier
|
||||
— on branche un fournisseur.**
|
||||
|
||||
### 3.5 IDs entier auto-increment, Audit, Timestampable/Blamable
|
||||
|
||||
Cohérent avec M0/M1/M2. Toutes les entités métier : `#[Auditable]`, `implements TimestampableInterface,
|
||||
BlamableInterface` + `use TimestampableBlamableTrait`. Entités auditées : `Tour`, `TourStop`.
|
||||
|
||||
## 4. Modèle de données
|
||||
|
||||
### 4.1 Adresses Tiers (M1 + M2) — **FAIT (ERP-122)**
|
||||
|
||||
Colonnes `latitude` NUMERIC(10,7), `longitude` NUMERIC(10,7), `geo_manual` BOOLEAN, `geocoded_at` TIMESTAMPTZ
|
||||
sur `client_address` et `supplier_address` ; contrat `GeolocatableAddressInterface` côté `Shared`.
|
||||
|
||||
### 4.2 `Tour` (tournée) — `tour`
|
||||
|
||||
| Champ | Type | Règle |
|
||||
|---|---|---|
|
||||
| `id` | int PK | |
|
||||
| `owner_id` | FK User | Commercial propriétaire. Tournée **personnelle** (RG-6.01). |
|
||||
| `label` | varchar(120) | Nom libre. NotBlank. |
|
||||
| `tour_date` | date | Date de réalisation. NotBlank. |
|
||||
| `departure_time` | time | Heure de départ (alimente les ETA). Défaut 08:00. |
|
||||
| `start_latitude` / `start_longitude` | numeric null | Point de départ (site commercial ou adresse libre). NULL → départ = 1re étape. |
|
||||
| `start_label` | varchar(180) null | Libellé du point de départ. |
|
||||
| `default_visit_minutes` | smallint default 30 | Durée de visite par défaut (temps total). |
|
||||
| `status` | enum `draft\|planned\|in_progress\|done` | Cycle de vie (RG-6.02). |
|
||||
| `total_distance_m` / `total_duration_s` | int null | Derniers totaux calculés (cache d'affichage). |
|
||||
|
||||
`#[Auditable]`, Timestampable/Blamable, soft delete (`deleted_at`). `GetCollection` paginée, filtrée par owner.
|
||||
|
||||
### 4.3 `TourStop` (étape) — `tour_stop`
|
||||
|
||||
| Champ | Type | Règle |
|
||||
|---|---|---|
|
||||
| `id` | int PK | |
|
||||
| `tour_id` | FK Tour | onDelete CASCADE. |
|
||||
| `tier_type` | string `client\|supplier\|…\|custom` | Cible (résolue via `VisitableInterface`). `custom` = point libre. |
|
||||
| `tier_id` | int null | ID du Tiers référentiel. NULL si `custom`. |
|
||||
| `address_id` | int null | Adresse précise visitée (un Tiers a plusieurs adresses — RG-6.03). NULL si `custom`. |
|
||||
| `custom_label` | varchar(180) null | Libellé du point libre (obligatoire ssi `custom`). |
|
||||
| `custom_address` | varchar(255) null | Adresse texte du point libre (ssi `custom`), géocodée. |
|
||||
| `custom_latitude` / `custom_longitude` | numeric null | Coordonnées du point libre (pin ajustable). |
|
||||
| `position` | smallint | Ordre dans la tournée (drag & drop). |
|
||||
| `visit_minutes` | smallint null | Durée de visite spécifique (sinon `tour.default_visit_minutes`). |
|
||||
| `leg_distance_m` / `leg_duration_s` | int null | Distance/temps **depuis l'étape précédente** (calculés). |
|
||||
| `eta` | time null | Heure d'arrivée estimée. |
|
||||
|
||||
`#[Auditable]`, Timestampable/Blamable. Unicité `(tour_id, position)`. **Pas** de rapport rattaché (scope réduit).
|
||||
|
||||
> Deux étapes peuvent viser le même Tiers (RG-6.07) — pas d'unicité sur `tier_id`.
|
||||
|
||||
## 5. API (API Platform — providers/processors, jamais de controller)
|
||||
|
||||
| Méthode | Endpoint | Sécurité | Note |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/tours` | `field_sales.tours.view` | Paginé, filtré sur `owner` courant (admin/bureau voient tout). |
|
||||
| POST | `/api/tours` | `field_sales.tours.manage` | Crée une tournée draft. |
|
||||
| GET/PATCH/DELETE | `/api/tours/{id}` | view / manage | DELETE = soft delete. |
|
||||
| POST | `/api/tours/{tourId}/stops` | `field_sales.tours.manage` | Sous-ressource (Link toProperty `tour`, pattern ClientAddress). |
|
||||
| PATCH/DELETE | `/api/tour_stops/{id}` | `field_sales.tours.manage` | PATCH `position` = drag & drop. |
|
||||
| POST | `/api/tours/{id}/compute` | `field_sales.tours.manage` | Recalcule legs + ETA + totaux (`HaversineRouteEngine`). |
|
||||
| POST | `/api/tours/{id}/optimize` | `field_sales.tours.manage` | Réordonne via `optimizeOrder()` puis recompute. |
|
||||
| POST | `/api/tours/{id}/duplicate` | `field_sales.tours.manage` | Duplique étapes + départ à une nouvelle `tourDate` (RG-6.13). |
|
||||
| GET | `/api/tours/{id}/roadbook.pdf` | `field_sales.tours.view` | Feuille de route PDF (skill `pdf`). |
|
||||
| GET | `/api/visitable_tiers?bbox=...&q=...&type=client,supplier` | `field_sales.tours.view` | Pins dans la zone visible (carte). Paginé / `?pagination=false`. |
|
||||
|
||||
Toutes les collections sont **paginées** (règle ABSOLUE n°13). `/api/visitable_tiers` retourne un Paginator,
|
||||
borné par `bbox`.
|
||||
|
||||
## 6. Écrans
|
||||
|
||||
### 6.1 Planification de tournée (carte interactive — responsive desktop + mobile)
|
||||
|
||||
Layout **split** inspiré de Badger/SPOTIO :
|
||||
|
||||
- **Carte interactive Leaflet** : pins des Tiers de la zone (couleur par type, filtrables). Sélection
|
||||
**lasso/rectangle** → ajoute les Tiers entourés comme étapes. Clic pin → popup (nom, adresse, « + Ajouter »).
|
||||
Tracé de la tournée dessiné par-dessus (polyline numérotée).
|
||||
- **Panneau tournée** : nom, date, **heure de départ**, point de départ, liste d'**étapes draggable**
|
||||
(n° + nom + adresse + ETA + temps depuis étape précédente), totaux (distance / durée / nb visites).
|
||||
Boutons **« Trajet logique »**, **« Optimiser »**, **« Dupliquer »**, **« PDF »**.
|
||||
- Chaque étape : menu **« Y aller »** (Waze / Google Maps / Plan via deep links), « Voir le Tiers ».
|
||||
- Ajout d'un **point libre `custom`** (libellé + adresse + pin).
|
||||
- En mobile, layout empilé : la navigation se fait via le bouton « Y aller » de chaque étape (pas de mode
|
||||
terrain dédié).
|
||||
|
||||
### 6.2 Onglet « Carte » dans la fiche Client / Fournisseur
|
||||
|
||||
Nouvel onglet **« Carte »** dans la fiche **Client (M1)** et **Fournisseur (M2)** : **mini-carte Leaflet**
|
||||
affichant **toutes les adresses géolocalisées du Tiers** (un marqueur par adresse, popup avec le libellé de
|
||||
l'adresse). Vue d'ensemble des implantations du Tiers. Le **pin reste ajustable** par adresse (réutilise le
|
||||
composant de l'onglet Adresse, déjà livré en ERP-122). Adresses sans coordonnées listées comme
|
||||
« à géolocaliser ». Onglet visible sous `field_sales.tours.view` ; masqué si le module `field_sales` est désactivé.
|
||||
|
||||
### 6.3 Ajustement du pin (fiche adresse) — **FAIT (ERP-122)**
|
||||
|
||||
Mini-carte Leaflet avec marqueur déplaçable dans le bloc adresse M1/M2 ; drag → `latitude/longitude` +
|
||||
`geo_manual = true` ; bouton « Re-géocoder depuis l'adresse ».
|
||||
|
||||
## 7. Géocodage des adresses — **FAIT (ERP-122)**
|
||||
|
||||
Service `GeocoderInterface` / `BanGeocoder` (api-adresse.data.gouv.fr). Correction manuelle systématique via le
|
||||
pin (`geo_manual = true` fige — RG-6.08).
|
||||
|
||||
## 8. RBAC — 3 miroirs obligatoires
|
||||
|
||||
Permissions du module `field_sales` (méthode `permissions()` de `FieldSalesModule.php`) — **uniquement les
|
||||
tournées** (plus de permissions `reports.*` depuis la réduction de scope) :
|
||||
|
||||
| Permission | Sens | Admin | Commerciale | Bureau |
|
||||
|---|---|---|---|---|
|
||||
| `field_sales.tours.view` | Voir les tournées + l'onglet Carte. | ✅ (toutes) | ✅ (les siennes) | ✅ (consultation) |
|
||||
| `field_sales.tours.manage` | Créer/éditer/optimiser/dupliquer/supprimer une tournée. | ✅ | ✅ | ❌ |
|
||||
|
||||
Attribution : **Commerciale + Admin** = manage ; **Bureau** = view ; Compta exclue. À synchroniser dans les
|
||||
**3 miroirs** (règle ABSOLUE n°8) : `config/sidebar.php` (section « Tournées » : item `tours` + i18n
|
||||
`sidebar.field_sales.*`), `frontend/tests/e2e/_fixtures/personas.ts`, `SeedE2ECommand.php`. Sync :
|
||||
`app:sync-permissions`.
|
||||
|
||||
## 9. Conventions & garde-fous (rappel)
|
||||
|
||||
- `declare(strict_types=1);` partout ; commentaires FR, code EN.
|
||||
- Entités métier : `#[Auditable]` + Timestampable/Blamable. Libellés i18n `audit.entity.field_sales_tour` /
|
||||
`_tourstop` dans `fr.json` (sinon `AuditableEntitiesHaveI18nLabelTest` casse `make test`).
|
||||
- Migration modulaire : `COMMENT ON COLUMN` sur **chaque** colonne (FR ≤ 200 car.) + helper
|
||||
`addStandardTimestampableBlamableComments()`.
|
||||
- Toute collection paginée (`CollectionsArePaginatedTest`).
|
||||
- Front : `useApi()` uniquement, composants `Malio*`, `MalioDataTable` + `usePaginatedList` pour les listes,
|
||||
**pas d'état de tableau dans l'URL**. Carte Leaflet = exception documentée.
|
||||
|
||||
## 10. Règles de gestion (RG)
|
||||
|
||||
| RG | Règle | Garde-fou |
|
||||
|---|---|---|
|
||||
| **RG-6.01** | Tournée **personnelle** (`owner`). Commerciale ne voit/édite que les siennes ; Admin/Bureau voient tout en lecture. | Filtre Provider sur `owner` + RBAC. |
|
||||
| **RG-6.02** | Cycle de vie `draft → planned → in_progress → done` (transitions libres en V1). | Enum + Assert\Choice. |
|
||||
| **RG-6.03** | Une étape sur Tiers référentiel vise une adresse de ce Tiers (qui en a plusieurs). Ne s'applique pas aux `custom`. | Assert\Callback. |
|
||||
| **RG-6.05** | Une étape n'entre dans le calcul que si son adresse a `latitude` ET `longitude`. Sinon « à géolocaliser », exclue des totaux. | `RouteEngine` + signalement front. |
|
||||
| **RG-6.07** | Deux étapes peuvent viser le même Tiers (repasser plus tard). Unicité uniquement sur `(tour_id, position)`. | Index unique partiel. |
|
||||
| **RG-6.08** | `geo_manual = true` fige les coordonnées (le géocodage auto ne réécrit plus). | Garde dans le géocodeur (FAIT). |
|
||||
| **RG-6.11** | `eta` = `departure_time` + Σ(trajets précédents) + Σ(durées de visite précédentes). | `RouteEngine::estimateLegDurations()`. |
|
||||
| **RG-6.12** | Une étape vise tout Tiers ou un point `custom`. Si `custom` : `tier_id`/`address_id` NULL, `custom_label` + coordonnées obligatoires. | Assert\Choice + Assert\Callback. |
|
||||
| **RG-6.13** | Dupliquer copie départ + étapes (ordre/adresses/durées) à une nouvelle date ; ne copie pas les calculs (ETA/legs recalculés). | Service `TourDuplicator`. |
|
||||
|
||||
## 11. Tests à automatiser
|
||||
|
||||
- **Architecture (cassent `make test`)** : `ColumnsHaveSqlCommentTest`, `AuditableEntitiesHaveI18nLabelTest`
|
||||
(`audit.entity.field_sales_tour` / `_tourstop`), `EntitiesAreTimestampableBlamableTest` (Tour, TourStop),
|
||||
`CollectionsArePaginatedTest`, `EntityConstraintsHaveFrenchMessageTest`.
|
||||
- **Back (PHPUnit)** : RG-6.03 (adresse hors Tiers → 422), RG-6.05 (étape sans coord exclue), RG-6.07 (doublon
|
||||
Tiers accepté), RG-6.11 (ETA), RG-6.12 (custom cohérent), RG-6.13 (duplication sans calculs), filtre `owner`,
|
||||
`HaversineRouteEngine` (ordre plus proche voisin sur un jeu de coordonnées connu).
|
||||
- **Front (Vitest)** : `usePaginatedList` sur tournées, composable de planification (réordonnancement, totaux,
|
||||
deep links), onglet Carte (marqueurs des adresses). **Pas de E2E** (règle d'or).
|
||||
|
||||
## 12. Hors-périmètre (HP)
|
||||
|
||||
- **HP-M6-1** : **rapport de visite** (compte-rendu, note /5, offres de prix, fichiers, catégorie, saisie
|
||||
vocale, historique des visites) — **retiré du scope (V0.2)**, à réintroduire dans un module/lot ultérieur si besoin.
|
||||
- **HP-M6-2** : mode terrain mobile dédié (vue du jour + check-in) — retiré ; navigation via l'écran de
|
||||
planification responsive.
|
||||
- **HP-M6-3** : routing routier réel + optimisation TSP (Matrix API) — V2 (V1 = heuristique Haversine).
|
||||
- **HP-M6-4** : suggestion automatique des Tiers « à visiter » (façon Portatour) — V2.
|
||||
- **HP-M6-5** : offline réel (PWA + synchro) — V3.
|
||||
- **HP-M6-6** : partage / affectation de tournées entre commerciaux, planning d'équipe — V3.
|
||||
- **HP-M6-7** : navigation multi-étapes poussée dans Waze (impossible techniquement) — navigation étape par étape.
|
||||
|
||||
## 13. Phasage
|
||||
|
||||
- **V1 (livrable)** : géoloc adresses + pin (FAIT) ; carte interactive + lasso ; tournée (création, drag & drop,
|
||||
heure de départ, point de départ) sur tout Tiers + point `custom` ; **« Trajet logique »** + ETA + totaux ;
|
||||
deep links Waze/Maps ; **duplication** ; **feuille de route PDF** ; **onglet Carte** dans les fiches
|
||||
Client/Fournisseur ; responsive desktop + mobile.
|
||||
- **V2** : bouton **« Optimiser »** (routing routier réel ORS/OSRM), temps trafic, suggestion des Tiers proches.
|
||||
- **V3** : offline réel, partage/affectation de tournées.
|
||||
|
||||
## 14. Risques / points ouverts
|
||||
|
||||
- **Coût/quota routing en V2** : multi-tenant → cache, debounce, plafonds par tenant.
|
||||
- **Limite Waze multi-étapes** : Waze ne prend qu'une destination → navigation étape par étape (assumé).
|
||||
- **Reste à cadrer techniquement** : périmètre de visibilité Bureau (toutes les tournées vs les siennes).
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tickets Lesstime (scope réduit V0.2)
|
||||
|
||||
TaskGroup Lesstime : **#28 — M6 Tournées commerciales terrain** (projet STARSEED #6). Tickets gros grain, chacun
|
||||
avec un **prompt Fable** prêt à coller (consigne « adapte-toi à la config actuelle » incluse).
|
||||
|
||||
| # | Réf | Ticket | Effort | Tag | État |
|
||||
|---|---|---|---|---|---|
|
||||
| M6.1 | ERP-122 | Géolocaliser les adresses Tiers (lat/lng + pin) | L | Back+Front | ✅ Fait |
|
||||
| M6.2 | ERP-123 | Fondations module field_sales + VisitableInterface + RBAC (tournées) | M | Back | Prêt à dev |
|
||||
| M6.3 | ERP-124 | Entités & API Tournée + Étape | L | Back | Prêt à dev |
|
||||
| M6.4 | ERP-125 | Calcul trajet, optimisation, duplication & roadbook PDF | L | Back | Prêt à dev |
|
||||
| M6.5 | ERP-127 | Carte interactive + écran planification (responsive) | L | Front | Prêt à dev |
|
||||
| M6.6 | ERP-129 | Onglet « Carte » dans les fiches Client & Fournisseur | M | Front | Prêt à dev |
|
||||
| M6.7 | ERP-130 | Vérification : garde-fous archi, tests RG & golden path | M | Back+Front | Prêt à dev |
|
||||
|
||||
Supprimés à la réduction de scope : **ERP-126** (rapport de visite) et **ERP-128** (mode terrain mobile + formulaire rapport).
|
||||
|
||||
Ordre d'exécution : M6.2 → M6.3 → M6.4 → M6.5 → M6.6 → M6.7.
|
||||
|
||||
---
|
||||
|
||||
### Sources d'inspiration (logiciels de référence)
|
||||
- Badger Maps — *Lasso* + carte : https://www.badgermapping.com/features/
|
||||
- SPOTIO — drag & drop des étapes + optimize : https://support.spotio.com/hc/en-us/articles/360061370754-Routing-How-to-Build-and-Manage-Routes
|
||||
- Portatour — multi-stop + recalcul auto : https://www.portatour.com/features/en
|
||||
- Nomadia Field Sales — carte + tournée mobile : https://www.nomadia.com/ressources/blog/logiciel-commerciaux-itinerants/
|
||||
@@ -1,119 +0,0 @@
|
||||
# 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**.
|
||||
@@ -1,19 +1,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])
|
||||
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>
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<MalioSidebar
|
||||
v-model="ui.sidebarCollapsed"
|
||||
:sections="translatedSections"
|
||||
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
|
||||
>
|
||||
<template #logo>
|
||||
<img src="/LOGO_MALIO.png" alt="Malio"/>
|
||||
@@ -26,7 +16,10 @@
|
||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<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">
|
||||
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12"/>
|
||||
<slot/>
|
||||
</main>
|
||||
</div>
|
||||
@@ -69,6 +62,6 @@ watch(() => route.path, () => {
|
||||
})
|
||||
|
||||
useHead({
|
||||
titleTemplate: (title) => title || 'Starseed',
|
||||
titleTemplate: (title) => title || 'Coltura',
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1 +1 @@
|
||||
/* Starseed - Custom styles */
|
||||
/* Coltura - Custom styles */
|
||||
|
||||
@@ -14,7 +14,7 @@ export default await nuxt(
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'starseed/custom-overrides',
|
||||
name: 'coltura/custom-overrides',
|
||||
rules: {
|
||||
// Indentation 4 espaces (convention CLAUDE.md)
|
||||
'vue/html-indent': ['error', 4],
|
||||
|
||||
@@ -10,11 +10,7 @@
|
||||
"confirm": "Confirmer",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"actions": "Actions",
|
||||
"comingSoon": {
|
||||
"title": "En cours de dev",
|
||||
"subtitle": "Cette fonctionnalité arrive bientôt."
|
||||
}
|
||||
"actions": "Actions"
|
||||
},
|
||||
"sidebar": {
|
||||
"administration": {
|
||||
@@ -27,7 +23,6 @@
|
||||
},
|
||||
"commercial": {
|
||||
"section": "Commercial",
|
||||
"clients": "Répertoire clients",
|
||||
"suppliers": "Répertoire fournisseurs"
|
||||
},
|
||||
"core": {
|
||||
@@ -37,438 +32,15 @@
|
||||
},
|
||||
"sites": {
|
||||
"admin": "Sites"
|
||||
},
|
||||
"catalog": {
|
||||
"categories": "Gestion des catégories"
|
||||
},
|
||||
"field_sales": {
|
||||
"section": "Tournées",
|
||||
"tours": "Tournées"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
"welcome": "Bienvenue sur Starseed"
|
||||
},
|
||||
"field_sales": {
|
||||
"tours": {
|
||||
"title": "Tournées",
|
||||
"add": "Nouvelle tournée",
|
||||
"empty": "Aucune tournée pour l'instant.",
|
||||
"column": {
|
||||
"label": "Nom",
|
||||
"date": "Date",
|
||||
"status": "Statut",
|
||||
"stops": "Étapes",
|
||||
"distance": "Distance",
|
||||
"duration": "Durée"
|
||||
},
|
||||
"status": {
|
||||
"draft": "Brouillon",
|
||||
"planned": "Planifiée",
|
||||
"in_progress": "En cours",
|
||||
"done": "Terminée"
|
||||
},
|
||||
"new": {
|
||||
"title": "Nouvelle tournée",
|
||||
"label": "Nom de la tournée",
|
||||
"date": "Date",
|
||||
"create": "Créer la tournée",
|
||||
"cancel": "Annuler",
|
||||
"error": "Impossible de créer la tournée."
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Planification",
|
||||
"back": "Retour aux tournées",
|
||||
"panel": {
|
||||
"title": "Tournée",
|
||||
"label": "Nom de la tournée",
|
||||
"date": "Date",
|
||||
"departureTime": "Heure de départ",
|
||||
"startLabel": "Point de départ",
|
||||
"defaultVisitMinutes": "Durée de visite (min)",
|
||||
"stops": "Étapes",
|
||||
"noStops": "Aucune étape. Sélectionnez des Tiers sur la carte ou ajoutez un point libre.",
|
||||
"distance": "Distance",
|
||||
"duration": "Durée totale",
|
||||
"visits": "Visites"
|
||||
},
|
||||
"actions": {
|
||||
"compute": "Trajet logique",
|
||||
"optimize": "Optimiser",
|
||||
"duplicate": "Dupliquer",
|
||||
"pdf": "PDF",
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"stop": {
|
||||
"eta": "Arrivée",
|
||||
"fromPrevious": "depuis l'étape précédente",
|
||||
"toGeolocate": "À géolocaliser",
|
||||
"goThere": "Y aller",
|
||||
"viewTier": "Voir le Tiers",
|
||||
"remove": "Supprimer l'étape",
|
||||
"waze": "Waze",
|
||||
"google": "Google Maps",
|
||||
"apple": "Plan (Apple)"
|
||||
},
|
||||
"custom": {
|
||||
"add": "Ajouter un point libre",
|
||||
"title": "Point libre",
|
||||
"label": "Libellé",
|
||||
"address": "Adresse",
|
||||
"confirm": "Ajouter le point",
|
||||
"cancel": "Annuler",
|
||||
"geocodeFailed": "Adresse introuvable — ajustez le pin sur la carte.",
|
||||
"hint": "Saisissez une adresse, elle sera géolocalisée automatiquement."
|
||||
},
|
||||
"map": {
|
||||
"typeClient": "Clients",
|
||||
"typeSupplier": "Fournisseurs",
|
||||
"search": "Rechercher un Tiers",
|
||||
"add": "Ajouter",
|
||||
"lassoHint": "Maintenez Maj et dessinez un rectangle pour sélectionner plusieurs Tiers."
|
||||
},
|
||||
"duplicateModal": {
|
||||
"title": "Dupliquer la tournée",
|
||||
"date": "Date de la nouvelle tournée",
|
||||
"confirm": "Dupliquer",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"toast": {
|
||||
"computeError": "Le calcul du trajet a échoué.",
|
||||
"optimizeError": "L'optimisation a échoué.",
|
||||
"duplicateError": "La duplication a échoué.",
|
||||
"saveError": "L'enregistrement a échoué.",
|
||||
"loadError": "Impossible de charger la tournée.",
|
||||
"stopError": "L'opération sur l'étape a échoué.",
|
||||
"duplicated": "Tournée dupliquée."
|
||||
}
|
||||
}
|
||||
"welcome": "Bienvenue sur Coltura"
|
||||
},
|
||||
"commercial": {
|
||||
"title": "Commercial",
|
||||
"welcome": "Module Commercial",
|
||||
"geo": {
|
||||
"title": "Position géographique",
|
||||
"toGeolocate": "À géolocaliser",
|
||||
"manualPin": "Pin ajusté manuellement",
|
||||
"dragHint": "Déplacez le marqueur pour ajuster la position exacte (lieu-dit, entrée de site...).",
|
||||
"regeocode": "Re-géocoder depuis l'adresse",
|
||||
"regeocodeFailed": "Adresse introuvable — position inchangée."
|
||||
},
|
||||
"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 activité"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"search": "Recherche",
|
||||
"categories": "Catégories",
|
||||
"sites": "Sites",
|
||||
"status": "Statut",
|
||||
"includeArchived": "Inclure 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 au répertoire",
|
||||
"loading": "Chargement du fournisseur…",
|
||||
"notFound": "Fournisseur introuvable.",
|
||||
"save": "Valider"
|
||||
},
|
||||
"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": {
|
||||
"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",
|
||||
"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",
|
||||
"export": "Exporter",
|
||||
"empty": "Aucun client pour l'instant.",
|
||||
"column": {
|
||||
"companyName": "Nom",
|
||||
"categories": "Catégories",
|
||||
"sites": "Site",
|
||||
"lastActivity": "Dernière activité"
|
||||
},
|
||||
"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": {
|
||||
"information": "Information",
|
||||
"contact": "Contact",
|
||||
"address": "Adresse",
|
||||
"transport": "Transport",
|
||||
"accounting": "Comptabilité",
|
||||
"statistics": "Statistiques",
|
||||
"reports": "Rapports",
|
||||
"exchanges": "Échanges"
|
||||
},
|
||||
"action": {
|
||||
"edit": "Modifier",
|
||||
"archive": "Archiver",
|
||||
"restore": "Restaurer"
|
||||
},
|
||||
"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.",
|
||||
"exportError": "L'export du répertoire clients a échoué. Réessayez.",
|
||||
"restoreConflict": "Impossible de restaurer : un client actif portant ce nom existe déjà."
|
||||
},
|
||||
"consultation": {
|
||||
"title": "Consultation client",
|
||||
"back": "Retour au répertoire",
|
||||
"loading": "Chargement du client…",
|
||||
"notFound": "Client introuvable.",
|
||||
"confirmArchive": {
|
||||
"title": "Archiver le client",
|
||||
"message": "Ce client n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
|
||||
},
|
||||
"confirmRestore": {
|
||||
"title": "Restaurer le client",
|
||||
"message": "Ce client réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le client",
|
||||
"back": "Retour au répertoire",
|
||||
"loading": "Chargement du client…",
|
||||
"notFound": "Client introuvable.",
|
||||
"save": "Valider"
|
||||
},
|
||||
"validation": {
|
||||
"informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.",
|
||||
"contactRequired": "Au moins un contact (nom ou prénom) est obligatoire.",
|
||||
"siteRequired": "Au moins un site Starseed doit être rattaché à l'adresse.",
|
||||
"billingEmailRequired": "L'email de facturation est obligatoire pour une adresse de facturation.",
|
||||
"bankRequiredForTransfer": "La banque est obligatoire pour un règlement par virement.",
|
||||
"ribRequiredForLcr": "Au moins un RIB complet est obligatoire pour un règlement par LCR.",
|
||||
"phoneFormat": "Format de téléphone invalide (attendu : XX XX XX XX XX).",
|
||||
"emailFormat": "Format d'email invalide.",
|
||||
"addressCategoryForbidden": "Une catégorie « Distributeur » ou « Courtier » ne peut pas qualifier une adresse."
|
||||
},
|
||||
"form": {
|
||||
"title": "Ajouter un client",
|
||||
"back": "Précédent",
|
||||
"submit": "Valider",
|
||||
"duplicateCompany": "Un client portant ce nom de société existe déjà.",
|
||||
"main": {
|
||||
"companyName": "Nom du client (Entreprise)",
|
||||
"categories": "Catégorie",
|
||||
"relation": "Distributeur / Courtier",
|
||||
"relationNone": "Aucun",
|
||||
"relationDistributor": "Dépend du distributeur",
|
||||
"relationBroker": "Dépend du courtier",
|
||||
"distributorName": "Nom du distributeur",
|
||||
"brokerName": "Nom du courtier",
|
||||
"triageService": "Prestation de triage"
|
||||
},
|
||||
"information": {
|
||||
"description": "Description",
|
||||
"competitors": "Concurrent",
|
||||
"foundedAt": "Date de création",
|
||||
"employeesCount": "Nombre de salariés",
|
||||
"revenueAmount": "CA",
|
||||
"profitAmount": "Résultat",
|
||||
"directorName": "Dirigeant"
|
||||
},
|
||||
"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}",
|
||||
"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",
|
||||
"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": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
"welcome": "Module Commercial"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Connexion",
|
||||
@@ -491,10 +63,7 @@
|
||||
},
|
||||
"sites": {
|
||||
"notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site."
|
||||
},
|
||||
"title": "Erreur",
|
||||
"generic": "Une erreur est survenue.",
|
||||
"unknown": "Erreur inconnue."
|
||||
}
|
||||
},
|
||||
"sites": {
|
||||
"selector": {
|
||||
@@ -509,37 +78,19 @@
|
||||
"delete": "Suppression"
|
||||
},
|
||||
"entity": {
|
||||
"core_user": "Utilisateur",
|
||||
"core_role": "Rôle",
|
||||
"core_permission": "Permission",
|
||||
"sites_site": "Site",
|
||||
"catalog_category": "Catégorie",
|
||||
"commercial_client": "Client",
|
||||
"commercial_clientaddress": "Adresse client",
|
||||
"commercial_clientcontact": "Contact client",
|
||||
"commercial_clientrib": "RIB client",
|
||||
"commercial_supplier": "Fournisseur",
|
||||
"commercial_supplieraddress": "Adresse fournisseur",
|
||||
"commercial_suppliercontact": "Contact fournisseur",
|
||||
"commercial_supplierrib": "RIB fournisseur",
|
||||
"fieldsales_tour": "Tournée",
|
||||
"fieldsales_tourstop": "Étape de tournée"
|
||||
"core_user": "Utilisateur",
|
||||
"core_role": "Rôle",
|
||||
"core_permission": "Permission",
|
||||
"sites_site": "Site"
|
||||
},
|
||||
"empty": "Aucune activité enregistrée",
|
||||
"no_results": "Aucun résultat pour ces filtres",
|
||||
"error": {
|
||||
"title": "Erreur",
|
||||
"message": "Impossible de charger le journal d'audit. Vérifiez les filtres ou réessayez."
|
||||
},
|
||||
"timeline": {
|
||||
"empty": "Aucun historique",
|
||||
"load_more": "Voir plus"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser",
|
||||
"date_range": "Date à date",
|
||||
"date_from": "Du",
|
||||
"date_to": "Au",
|
||||
"entity_type": "Type d'entité",
|
||||
@@ -557,8 +108,7 @@
|
||||
"success": {
|
||||
"auth": {
|
||||
"logout": "Deconnexion reussie"
|
||||
},
|
||||
"title": "Succès"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"roles": {
|
||||
@@ -670,44 +220,6 @@
|
||||
"updated": "Site mis à jour avec succès",
|
||||
"deleted": "Site supprimé avec succès"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"title": "Gestion des catégories",
|
||||
"newCategory": "Ajouter",
|
||||
"editCategory": "Modifier la catégorie",
|
||||
"createCategory": "Créer une catégorie",
|
||||
"noCategories": "Aucune catégorie pour l'instant.",
|
||||
"table": {
|
||||
"name": "Nom",
|
||||
"types": "Types"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"search": "Recherche",
|
||||
"types": "Types de catégorie",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
"form": {
|
||||
"name": "Nom",
|
||||
"types": "Types de catégorie"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Le nom est obligatoire.",
|
||||
"nameLength": "Le nom doit faire entre 2 et 120 caractères.",
|
||||
"typesRequired": "Sélectionnez au moins un type de catégorie."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Supprimer la catégorie",
|
||||
"message": "Êtes-vous sûr de vouloir supprimer la catégorie \"{name}\" ? Cette action est irréversible."
|
||||
},
|
||||
"toast": {
|
||||
"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à.",
|
||||
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<template>
|
||||
<MalioModal
|
||||
:model-value="modelValue"
|
||||
modal-class="max-w-md"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold text-neutral-900">
|
||||
{{ t('admin.categories.delete.title') }}
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<p class="text-sm text-neutral-600">
|
||||
{{ t('admin.categories.delete.message', { name: categoryName }) }}
|
||||
</p>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
:label="t('common.cancel')"
|
||||
variant="secondary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
:disabled="loading"
|
||||
@click="emit('confirm')"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
categoryName: string
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
confirm: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,164 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
drawer-class="w-full max-w-lg"
|
||||
header-class="border-b border-black"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-2xl font-bold">
|
||||
{{ headerLabel }}
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave">
|
||||
<!-- Nom (RG-1.02 obligatoire / RG-1.04 longueur 2-120 apres trim).
|
||||
Erreur miroir client + erreurs server-side (422) mappees sur ce champ. -->
|
||||
<MalioInputText
|
||||
v-model="form.name.value"
|
||||
:label="t('admin.categories.form.name')"
|
||||
input-class="w-full"
|
||||
:max-length="120"
|
||||
:error="form.errors.name"
|
||||
required
|
||||
/>
|
||||
|
||||
<!-- 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.types')"
|
||||
:error="form.errors.categoryTypes"
|
||||
:display-tag="true"
|
||||
:disabled="loadingTypes"
|
||||
required
|
||||
/>
|
||||
</form>
|
||||
|
||||
<!-- Footer fixe : depuis 1.7.1 le slot #footer est un frere du body
|
||||
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
v-if="canShowDelete"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
button-class="w-m-btn-action"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
button-class="w-m-btn-action"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canShowSave"
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-m-btn-action"
|
||||
:disabled="form.submitting.value || loadingTypes"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Category } from '~/modules/catalog/types/category'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { can } = usePermissions()
|
||||
const { types, loadingTypes, fetchTypes } = useCategoriesAdmin()
|
||||
// Instance dediee de form pour ce drawer — state isole (cf. useCategoryForm
|
||||
// n'est pas singleton, contrairement a useCategoriesAdmin).
|
||||
const form = useCategoryForm()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
category: Category | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
saved: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
// 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 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
|
||||
// creation on affiche un bouton Annuler a la place.
|
||||
const canShowDelete = computed(
|
||||
() => !isCreateMode.value && can('catalog.categories.manage'),
|
||||
)
|
||||
|
||||
// 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(
|
||||
() => isCreateMode.value || can('catalog.categories.manage'),
|
||||
)
|
||||
|
||||
const typeOptions = computed(() =>
|
||||
types.value.map(ct => ({
|
||||
label: ct.label,
|
||||
value: ct.id,
|
||||
})),
|
||||
)
|
||||
|
||||
// Re-initialise le form quand la categorie selectionnee change (clic sur une
|
||||
// autre ligne sans fermer le drawer entre-temps).
|
||||
watch(() => props.category, (cat) => {
|
||||
form.loadFrom(cat)
|
||||
}, { immediate: true })
|
||||
|
||||
// A chaque ouverture du drawer : reload du form + refresh des types (au cas
|
||||
// ou un type aurait ete ajoute en arriere-plan depuis le dernier fetch — pas
|
||||
// d'optimisation cache au M0, le referentiel est petit).
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
form.loadFrom(props.category)
|
||||
fetchTypes()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const result = isCreateMode.value
|
||||
? await form.submitCreate()
|
||||
: props.category
|
||||
? await form.submitUpdate(props.category.id)
|
||||
: null
|
||||
if (result) {
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,155 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { CategoryType } from '~/modules/catalog/types/category'
|
||||
import type { HydraCollection } from '~/shared/utils/api'
|
||||
|
||||
// Mock du store auth : useCategoriesAdmin s'auto-enregistre via
|
||||
// `onAuthSessionCleared(...)` au chargement du module. On stubbe pour
|
||||
// eviter de charger Pinia et la vraie store (pas necessaire ici).
|
||||
vi.mock('~/shared/stores/auth', () => ({
|
||||
onAuthSessionCleared: vi.fn(),
|
||||
}))
|
||||
|
||||
// Le client API est un auto-import Nuxt. On le remplace par un stub
|
||||
// global pour intercepter les appels et controler les reponses dans
|
||||
// chaque test (cf. pattern utilise dans useCurrentSite.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 vi.mock / vi.stubGlobal : le module n'est evalue qu'a
|
||||
// ce moment-la, donc le mock auth est bien actif au top-level.
|
||||
const { useCategoriesAdmin } = await import('../useCategoriesAdmin')
|
||||
|
||||
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
|
||||
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
|
||||
|
||||
function makeHydra<T>(items: T[]): HydraCollection<T> {
|
||||
return {
|
||||
totalItems: items.length,
|
||||
member: items,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apres ERP-73, `useCategoriesAdmin` ne porte plus la liste paginee des
|
||||
* categories (elle est geree par `usePaginatedList<Category>` cote page).
|
||||
* Le composable se concentre sur le referentiel CategoryType (lecture
|
||||
* seule, ≤ 5 entrees connues) charge en une fois via `?pagination=false`.
|
||||
*/
|
||||
describe('useCategoriesAdmin', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
// Reset systematique du state singleton entre tests : sans ca,
|
||||
// les types charges dans un test fuiteraient dans le suivant.
|
||||
const { resetCategoriesAdmin } = useCategoriesAdmin()
|
||||
resetCategoriesAdmin()
|
||||
})
|
||||
|
||||
describe('fetchTypes', () => {
|
||||
it('appelle GET /category_types avec ?pagination=false (echappatoire selects)', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra<CategoryType>([]))
|
||||
const { fetchTypes } = useCategoriesAdmin()
|
||||
|
||||
await fetchTypes()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/category_types',
|
||||
{ pagination: 'false' },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('peuple types.value depuis le champ Hydra member', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra([TYPE_VENTE, TYPE_ACHAT]))
|
||||
const { fetchTypes, types } = useCategoriesAdmin()
|
||||
|
||||
await fetchTypes()
|
||||
|
||||
expect(types.value).toEqual([TYPE_VENTE, TYPE_ACHAT])
|
||||
})
|
||||
|
||||
it('peuple error.value et vide types en cas d echec', async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error('500'))
|
||||
const { fetchTypes, types, error, loadingTypes } = useCategoriesAdmin()
|
||||
types.value = [TYPE_VENTE]
|
||||
|
||||
await fetchTypes()
|
||||
|
||||
expect(types.value).toEqual([])
|
||||
expect(error.value).toContain('500')
|
||||
expect(loadingTypes.value).toBe(false)
|
||||
})
|
||||
|
||||
it('passe loadingTypes a true pendant la requete et false apres', async () => {
|
||||
let resolveRequest: (v: HydraCollection<CategoryType>) => void = () => {}
|
||||
mockGet.mockImplementationOnce(
|
||||
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||
)
|
||||
const { fetchTypes, loadingTypes } = useCategoriesAdmin()
|
||||
|
||||
const pending = fetchTypes()
|
||||
expect(loadingTypes.value).toBe(true)
|
||||
|
||||
resolveRequest(makeHydra<CategoryType>([]))
|
||||
await pending
|
||||
|
||||
expect(loadingTypes.value).toBe(false)
|
||||
})
|
||||
|
||||
it('gere une reponse sans champ member (fallback tableau vide)', async () => {
|
||||
mockGet.mockResolvedValueOnce({
|
||||
totalItems: 0,
|
||||
} as unknown as HydraCollection<CategoryType>)
|
||||
const { fetchTypes, types } = useCategoriesAdmin()
|
||||
|
||||
await fetchTypes()
|
||||
|
||||
expect(types.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetCategoriesAdmin', () => {
|
||||
it('vide types, loadingTypes et error', () => {
|
||||
const { resetCategoriesAdmin, types, loadingTypes, error }
|
||||
= useCategoriesAdmin()
|
||||
// Pre-peuple le state pour verifier la purge effective.
|
||||
types.value = [TYPE_VENTE]
|
||||
loadingTypes.value = true
|
||||
error.value = 'oops'
|
||||
|
||||
resetCategoriesAdmin()
|
||||
|
||||
expect(types.value).toEqual([])
|
||||
expect(loadingTypes.value).toBe(false)
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('singleton', () => {
|
||||
it('deux appels a useCategoriesAdmin() partagent la meme ref types', () => {
|
||||
const a = useCategoriesAdmin()
|
||||
const b = useCategoriesAdmin()
|
||||
|
||||
// Les fonctions sont reinstanciees a chaque appel mais les refs
|
||||
// doivent etre rigoureusement les memes (state au niveau module).
|
||||
expect(a.types).toBe(b.types)
|
||||
expect(a.loadingTypes).toBe(b.loadingTypes)
|
||||
expect(a.error).toBe(b.error)
|
||||
})
|
||||
|
||||
it('une mutation via une instance est visible depuis une autre instance', () => {
|
||||
const a = useCategoriesAdmin()
|
||||
const b = useCategoriesAdmin()
|
||||
|
||||
a.types.value = [TYPE_VENTE]
|
||||
|
||||
expect(b.types.value).toEqual([TYPE_VENTE])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,487 +0,0 @@
|
||||
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.
|
||||
const mockGet = vi.hoisted(() => vi.fn())
|
||||
const mockPost = vi.hoisted(() => vi.fn())
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
const mockDelete = 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: mockDelete,
|
||||
}))
|
||||
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.
|
||||
vi.stubGlobal('useI18n', () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}::${JSON.stringify(params)}` : key,
|
||||
}))
|
||||
|
||||
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
|
||||
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
|
||||
|
||||
const CAT: Category = {
|
||||
id: 42,
|
||||
name: 'Vis',
|
||||
categoryTypes: [TYPE_VENTE],
|
||||
deletedAt: null,
|
||||
createdAt: '2026-01-01T10:00:00+00:00',
|
||||
updatedAt: '2026-01-01T10:00:00+00:00',
|
||||
createdBy: null,
|
||||
updatedBy: null,
|
||||
}
|
||||
|
||||
describe('useCategoryForm', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
mockDelete.mockReset()
|
||||
mockToastSuccess.mockReset()
|
||||
mockToastError.mockReset()
|
||||
})
|
||||
|
||||
describe('loadFrom', () => {
|
||||
it('pre-remplit le formulaire depuis une categorie existante (multi-types)', () => {
|
||||
const form = useCategoryForm()
|
||||
|
||||
form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
|
||||
|
||||
expect(form.name.value).toBe('Vis')
|
||||
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.categoryTypeIds.value = [99]
|
||||
|
||||
form.loadFrom(null)
|
||||
|
||||
expect(form.name.value).toBe('')
|
||||
expect(form.categoryTypeIds.value).toEqual([])
|
||||
})
|
||||
|
||||
it('reinitialise le snapshot initial → isDirty=false juste apres', () => {
|
||||
const form = useCategoryForm()
|
||||
|
||||
form.loadFrom(CAT)
|
||||
|
||||
expect(form.isDirty.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDirty', () => {
|
||||
it('passe a true des qu une valeur diverge du snapshot initial', () => {
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
expect(form.isDirty.value).toBe(false)
|
||||
|
||||
form.name.value = 'Vis modifie'
|
||||
|
||||
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.categoryTypeIds.value = [1]
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
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.categoryTypeIds.value = [1]
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
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.categoryTypeIds.value = [1]
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
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.categoryTypeIds.value = [1]
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.name).toBe('admin.categories.validation.nameLength')
|
||||
})
|
||||
|
||||
it('signale erreur si aucun type selectionne (RG-1.05)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeIds.value = []
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.categoryTypes).toBe('admin.categories.validation.typesRequired')
|
||||
})
|
||||
|
||||
it('passe quand name et au moins un type sont valides', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeIds.value = [1, 2]
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(form.errors).toEqual({})
|
||||
})
|
||||
|
||||
it('reinitialise les erreurs avant chaque validation', () => {
|
||||
const form = useCategoryForm()
|
||||
// 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).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitCreate', () => {
|
||||
it('appelle POST /categories avec body { name trimme, categoryTypes en IRI[] }', async () => {
|
||||
mockPost.mockResolvedValueOnce(CAT)
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ' Vis '
|
||||
form.categoryTypeIds.value = [1, 2]
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/categories',
|
||||
{ name: 'Vis', categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
|
||||
{ toast: false },
|
||||
)
|
||||
expect(result).toEqual(CAT)
|
||||
})
|
||||
|
||||
it('ne declenche aucun appel API si la validation client echoue', async () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ''
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('declenche un toast de succes en cas de creation reussie', async () => {
|
||||
mockPost.mockResolvedValueOnce(CAT)
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
await form.submitCreate()
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||
title: 'success.title',
|
||||
message: 'admin.categories.toast.created',
|
||||
})
|
||||
})
|
||||
|
||||
it('mappe un 409 (RG-1.07) sur errors.name + toast erreur avec le nom', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: { status: 409, _data: {} },
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
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.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')
|
||||
})
|
||||
|
||||
it('mappe un 422 violations sur les champs concernes (errors.name)', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: {
|
||||
violations: [
|
||||
{ propertyPath: 'name', message: 'name should not be blank.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(result).toBeNull()
|
||||
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 une violation sur categoryTypes (hydra:violations alternative)', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: {
|
||||
'hydra:violations': [
|
||||
{ propertyPath: 'categoryTypes', message: 'Sélectionnez au moins un type de catégorie.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
await form.submitCreate()
|
||||
|
||||
expect(form.errors.categoryTypes).toBe('Sélectionnez au moins un type de catégorie.')
|
||||
})
|
||||
|
||||
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.categoryTypeIds.value = [1]
|
||||
|
||||
await form.submitCreate()
|
||||
|
||||
// Pas d'erreur inline par champ : l'erreur transverse part en toast.
|
||||
expect(form.errors).toEqual({})
|
||||
expect(mockToastError).toHaveBeenCalledWith({
|
||||
title: 'errors.title',
|
||||
message: 'Boom server',
|
||||
})
|
||||
})
|
||||
|
||||
it('passe submitting a true pendant la requete et a false apres', async () => {
|
||||
let resolveRequest: (v: Category) => void = () => {}
|
||||
mockPost.mockImplementationOnce(
|
||||
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||
)
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeIds.value = [1]
|
||||
|
||||
const pending = form.submitCreate()
|
||||
expect(form.submitting.value).toBe(true)
|
||||
|
||||
resolveRequest(CAT)
|
||||
await pending
|
||||
|
||||
expect(form.submitting.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitUpdate', () => {
|
||||
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' // 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: CAT.name, categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('envoie un PATCH complet meme sans modification (save a tout moment)', async () => {
|
||||
mockPatch.mockResolvedValueOnce(CAT)
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
// Aucune modification : le PATCH part quand meme avec le payload complet.
|
||||
|
||||
const result = await form.submitUpdate(42)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it('declenche un toast de succes au PATCH reussi', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.name.value = 'Vis V2'
|
||||
|
||||
await form.submitUpdate(42)
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||
title: 'success.title',
|
||||
message: 'admin.categories.toast.updated',
|
||||
})
|
||||
})
|
||||
|
||||
it('mappe le 409 sur errors.name en mode update aussi', async () => {
|
||||
mockPatch.mockRejectedValueOnce({
|
||||
response: { status: 409, _data: {} },
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.name.value = 'Doublon'
|
||||
|
||||
const result = await form.submitUpdate(42)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(form.errors.name).toContain('admin.categories.toast.duplicate')
|
||||
expect(form.errors.name).toContain('"name":"Doublon"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitDelete', () => {
|
||||
it('appelle DELETE /categories/{id} et declenche un toast succes', async () => {
|
||||
mockDelete.mockResolvedValueOnce(undefined)
|
||||
const form = useCategoryForm()
|
||||
|
||||
const ok = await form.submitDelete(42)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false })
|
||||
expect(ok).toBe(true)
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||
title: 'success.title',
|
||||
message: 'admin.categories.toast.deleted',
|
||||
})
|
||||
})
|
||||
|
||||
it('retourne false et toast erreur en cas d echec', async () => {
|
||||
mockDelete.mockRejectedValueOnce({
|
||||
response: { status: 500, _data: { detail: 'down' } },
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
|
||||
const ok = await form.submitDelete(42)
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(mockToastError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset', () => {
|
||||
it('vide le formulaire et les erreurs', () => {
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.name.value = ''
|
||||
form.validate() // peuple errors.name
|
||||
form.submitting.value = true
|
||||
|
||||
form.reset()
|
||||
|
||||
expect(form.name.value).toBe('')
|
||||
expect(form.categoryTypeIds.value).toEqual([])
|
||||
expect(form.errors).toEqual({})
|
||||
expect(form.submitting.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isolation', () => {
|
||||
it('deux instances useCategoryForm() ont des states independants', () => {
|
||||
const a = useCategoryForm()
|
||||
const b = useCategoryForm()
|
||||
|
||||
a.name.value = 'A'
|
||||
b.name.value = 'B'
|
||||
|
||||
expect(a.name.value).toBe('A')
|
||||
expect(b.name.value).toBe('B')
|
||||
// Les refs sont distinctes (pas singleton — chaque drawer son state).
|
||||
expect(a.name).not.toBe(b.name)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Composable de chargement du referentiel CategoryType (M0 — Gestion des
|
||||
* categories).
|
||||
*
|
||||
* Apres ERP-73 (composable de liste paginee), la liste des categories
|
||||
* elle-meme passe par `usePaginatedList<Category>` directement dans
|
||||
* `admin/categories.vue` — c'est un etat propre a la page (pagination,
|
||||
* filtres, tri locaux). Ce composable se concentre donc sur le
|
||||
* referentiel CategoryType : petite collection lue une fois et reutilisee
|
||||
* dans le drawer (select de type) → singleton volontaire pour eviter de
|
||||
* la recharger a chaque ouverture du drawer.
|
||||
*
|
||||
* State singleton au niveau module : reset automatique au logout via
|
||||
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md), et reset
|
||||
* explicite via `resetCategoriesAdmin()` appele depuis logout.vue.
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import type { CategoryType } from '~/modules/catalog/types/category'
|
||||
import type { HydraCollection } from '~/shared/utils/api'
|
||||
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
||||
|
||||
/**
|
||||
* CategoryType est un referentiel lecture-seule (RG-1.06) avec une
|
||||
* cardinalite minuscule (≤ 5 entrees connues). On force `pagination=false`
|
||||
* pour recuperer toutes les entrees en un appel et alimenter le select du
|
||||
* drawer sans pagination — echappatoire prevue par
|
||||
* `pagination_client_enabled: true` cote API Platform.
|
||||
*/
|
||||
const NO_PAGINATION_QUERY = { pagination: 'false' } as const
|
||||
|
||||
const types = ref<CategoryType[]>([])
|
||||
const loadingTypes = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
function resetCategoriesAdminState(): void {
|
||||
types.value = []
|
||||
loadingTypes.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// Auto-enregistrement singleton : purge le state sur 401/clearSession
|
||||
// pour eviter qu'un user suivant (connecte sur le meme onglet) voie le
|
||||
// referentiel de l'ancien tenant. Le logout volontaire (page logout.vue)
|
||||
// appelle directement `resetCategoriesAdmin()` ci-dessous.
|
||||
onAuthSessionCleared(resetCategoriesAdminState)
|
||||
|
||||
export function useCategoriesAdmin() {
|
||||
const api = useApi()
|
||||
|
||||
/**
|
||||
* Charge le referentiel CategoryType. Appele a l'ouverture de la page
|
||||
* admin pour que le select du drawer ait deja les options pretes au
|
||||
* moment de la creation/edition.
|
||||
*
|
||||
* Toast desactive : on stocke l'erreur dans `error` plutot que de
|
||||
* spammer un toast — le drawer affichera l'erreur inline s'il y a lieu.
|
||||
*/
|
||||
async function fetchTypes(): Promise<void> {
|
||||
loadingTypes.value = true
|
||||
try {
|
||||
const data = await api.get<HydraCollection<CategoryType>>(
|
||||
'/category_types',
|
||||
NO_PAGINATION_QUERY,
|
||||
{ toast: false },
|
||||
)
|
||||
types.value = data.member ?? []
|
||||
} catch (e) {
|
||||
types.value = []
|
||||
error.value = (e as Error)?.message ?? 'Erreur de chargement des types'
|
||||
} finally {
|
||||
loadingTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset explicite — appele depuis `logout.vue` apres `auth.logout()`
|
||||
* pour garantir que la prochaine session reparte sur un state propre
|
||||
* meme si `clearSession()` n'a pas ete declenche (cas logout volontaire).
|
||||
*/
|
||||
function resetCategoriesAdmin(): void {
|
||||
resetCategoriesAdminState()
|
||||
}
|
||||
|
||||
return {
|
||||
types,
|
||||
loadingTypes,
|
||||
error,
|
||||
fetchTypes,
|
||||
resetCategoriesAdmin,
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* Composable de formulaire categorie (M0 — Gestion des categories).
|
||||
*
|
||||
* Centralise la logique de validation client + appels API (POST / PATCH /
|
||||
* DELETE) du drawer de creation/edition. Contrairement a
|
||||
* `useCategoriesAdmin` qui porte un state singleton partage entre composants,
|
||||
* ce composable est instancie par formulaire (les refs vivent dans la
|
||||
* fonction `useCategoryForm()`) — chaque drawer ouvert a son propre state
|
||||
* isole.
|
||||
*
|
||||
* Validations client en miroir des regles back (RG-1.02 / RG-1.04 / RG-1.05) :
|
||||
* elles servent juste a eviter l'aller-retour reseau evitable. Le serveur
|
||||
* revalide toujours (defense en profondeur).
|
||||
*
|
||||
* 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'
|
||||
|
||||
/**
|
||||
* Erreur HTTP capturee par ofetch. On expose juste les champs utilises ici
|
||||
* (status et payload data) pour eviter de typer toute la lib.
|
||||
*/
|
||||
interface ApiFetchError {
|
||||
response?: {
|
||||
status?: number
|
||||
_data?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export function useCategoryForm() {
|
||||
const api = useApi()
|
||||
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 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 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
|
||||
|| !sameIds(categoryTypeIds.value, initialCategoryTypeIds.value),
|
||||
)
|
||||
|
||||
/**
|
||||
* Pre-remplit le formulaire a partir d'une categorie existante (mode
|
||||
* consultation/edition) ou vide (mode creation). Reinitialise les
|
||||
* erreurs et le snapshot initial pour repartir d'un etat propre.
|
||||
*/
|
||||
function loadFrom(category: Category | null): void {
|
||||
formErrors.clearErrors()
|
||||
if (category) {
|
||||
const ids = category.categoryTypes.map(t => t.id)
|
||||
name.value = category.name
|
||||
categoryTypeIds.value = [...ids]
|
||||
initialName.value = category.name
|
||||
initialCategoryTypeIds.value = [...ids]
|
||||
} else {
|
||||
name.value = ''
|
||||
categoryTypeIds.value = []
|
||||
initialName.value = ''
|
||||
initialCategoryTypeIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation client miroir des RG back. Renvoie true si tout passe et
|
||||
* peuple `errors` sinon. Le trim est applique cote client (miroir RG-1.03)
|
||||
* mais le serveur retrim de toute facon — pas de risque de divergence.
|
||||
*/
|
||||
function validate(): boolean {
|
||||
formErrors.clearErrors()
|
||||
const trimmedName = name.value.trim()
|
||||
|
||||
// RG-1.02 — name obligatoire (vide / whitespace-only).
|
||||
if (trimmedName === '') {
|
||||
formErrors.setError('name', t('admin.categories.validation.nameRequired'))
|
||||
} else if (trimmedName.length < 2 || trimmedName.length > 120) {
|
||||
// RG-1.04 — longueur 2-120 apres trim.
|
||||
formErrors.setError('name', t('admin.categories.validation.nameLength'))
|
||||
}
|
||||
|
||||
// RG-1.05 — au moins un type obligatoire.
|
||||
if (categoryTypeIds.value.length === 0) {
|
||||
formErrors.setError('categoryTypes', t('admin.categories.validation.typesRequired'))
|
||||
}
|
||||
|
||||
return !formErrors.errors.name && !formErrors.errors.categoryTypes
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(),
|
||||
categoryTypes: categoryTypeIds.value.map(id => `/api/category_types/${id}`),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
if (status === 409) {
|
||||
const duplicateMessage = t('admin.categories.toast.duplicate', {
|
||||
name: attemptedName,
|
||||
})
|
||||
formErrors.setError('name', duplicateMessage)
|
||||
toast.error({ title: t('errors.title'), message: duplicateMessage })
|
||||
return true
|
||||
}
|
||||
|
||||
return formErrors.handleApiError(e, { fallbackMessage: t('errors.generic') })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/categories. Renvoie la categorie creee, ou `null` si la
|
||||
* validation client a echoue ou si le serveur a renvoye une erreur. Le
|
||||
* caller (drawer) decide quoi faire en fonction (fermer ou rester ouvert).
|
||||
*/
|
||||
async function submitCreate(): Promise<Category | null> {
|
||||
if (!validate()) return null
|
||||
submitting.value = true
|
||||
const payload = buildCreatePayload()
|
||||
try {
|
||||
const created = await api.post<Category>('/categories', payload, {
|
||||
toast: false,
|
||||
})
|
||||
toast.success({
|
||||
title: t('success.title'),
|
||||
message: t('admin.categories.toast.created'),
|
||||
})
|
||||
return created
|
||||
} catch (e) {
|
||||
handleApiError(e, String(payload.name))
|
||||
return null
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
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: t('success.title'),
|
||||
message: t('admin.categories.toast.updated'),
|
||||
})
|
||||
return updated
|
||||
} catch (e) {
|
||||
const attemptedName = typeof payload.name === 'string'
|
||||
? payload.name
|
||||
: name.value.trim()
|
||||
handleApiError(e, attemptedName)
|
||||
return null
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/categories/{id} → soft delete (RG-1.12). Le serveur pose
|
||||
* `deleted_at = now()` et retourne 204. Renvoie true en cas de succes,
|
||||
* false sinon (avec toast erreur deja affiche).
|
||||
*/
|
||||
async function submitDelete(id: number): Promise<boolean> {
|
||||
submitting.value = true
|
||||
formErrors.clearErrors()
|
||||
try {
|
||||
await api.delete(`/categories/${id}`, {}, { toast: false })
|
||||
toast.success({
|
||||
title: t('success.title'),
|
||||
message: t('admin.categories.toast.deleted'),
|
||||
})
|
||||
return true
|
||||
} catch (e) {
|
||||
handleApiError(e, name.value)
|
||||
return false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset complet du formulaire — utilise par le drawer apres save ou
|
||||
* fermeture pour ne pas garder de donnees stale entre deux ouvertures.
|
||||
*/
|
||||
function reset(): void {
|
||||
name.value = ''
|
||||
categoryTypeIds.value = []
|
||||
initialName.value = ''
|
||||
initialCategoryTypeIds.value = []
|
||||
formErrors.clearErrors()
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
name,
|
||||
categoryTypeIds,
|
||||
errors: formErrors.errors,
|
||||
submitting,
|
||||
isDirty,
|
||||
// Methods
|
||||
loadFrom,
|
||||
validate,
|
||||
submitCreate,
|
||||
submitUpdate,
|
||||
submitDelete,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export default defineNuxtConfig({})
|
||||
@@ -1,303 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('admin.categories.title') }}
|
||||
<template #actions>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Table des categories. Tri serveur (name ASC, RG-1.10) +
|
||||
pagination serveur via usePaginatedList (#73). Le composable
|
||||
remplace l'ancien chargement « tout en un coup » a volumetrie
|
||||
cible ≤ 300 — la pagination est desormais alignee sur la regle
|
||||
projet (toute collection paginee, regle ABSOLUE n°13). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="categoryItems"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
:row-clickable="true"
|
||||
:empty-message="t('admin.categories.noCategories')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
/>
|
||||
|
||||
<!-- Drawer creation / consultation / edition. -->
|
||||
<CategoryDrawer
|
||||
v-model="drawerOpen"
|
||||
:category="selectedCategory"
|
||||
@saved="onCategorySaved"
|
||||
@delete="onDeleteRequest"
|
||||
/>
|
||||
|
||||
<!-- Modale de confirmation suppression (soft delete cote serveur). -->
|
||||
<CategoryDeleteModal
|
||||
v-model="deleteModalOpen"
|
||||
:category-name="categoryToDelete?.name ?? ''"
|
||||
: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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Category } from '~/modules/catalog/types/category'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { can } = usePermissions()
|
||||
const { types, fetchTypes } = useCategoriesAdmin()
|
||||
const { submitDelete } = useCategoryForm()
|
||||
|
||||
useHead({ title: t('admin.categories.title') })
|
||||
|
||||
const canManage = computed(() => can('catalog.categories.manage'))
|
||||
|
||||
// Pagination serveur via le composable partage (#73). Le CategoryProvider
|
||||
// applique deja name ASC (RG-1.10) — pas besoin de defaultSort cote front
|
||||
// tant qu'aucun OrderFilter n'est expose.
|
||||
const {
|
||||
items: categories,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: fetchCategories,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
setFilters,
|
||||
} = usePaginatedList<Category>({ url: '/categories' })
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const selectedCategory = ref<Category | null>(null)
|
||||
const deleteModalOpen = ref(false)
|
||||
const categoryToDelete = ref<Category | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
// 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: 'typesLabel', label: t('admin.categories.table.types') },
|
||||
]
|
||||
|
||||
const categoryItems = computed(() =>
|
||||
categories.value.map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
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)
|
||||
}
|
||||
|
||||
function onRowClick(item: Record<string, unknown>) {
|
||||
const category = getCategoryById(item.id as number)
|
||||
if (category) openEditDrawer(category)
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
selectedCategory.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEditDrawer(category: Category) {
|
||||
selectedCategory.value = category
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function onDeleteRequest() {
|
||||
if (!selectedCategory.value) return
|
||||
categoryToDelete.value = selectedCategory.value
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete via le composable de form (qui gere toast + erreur). Refresh
|
||||
* de la liste a la fin pour retirer la ligne. L'index unique partiel
|
||||
* autorise une recreation ulterieure avec le meme couple (name, type) —
|
||||
* RG-1.07.
|
||||
*/
|
||||
async function handleDelete(): Promise<void> {
|
||||
if (!categoryToDelete.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
const ok = await submitDelete(categoryToDelete.value.id)
|
||||
if (ok) {
|
||||
deleteModalOpen.value = false
|
||||
categoryToDelete.value = null
|
||||
drawerOpen.value = false
|
||||
await fetchCategories()
|
||||
}
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCategorySaved() {
|
||||
fetchCategories()
|
||||
}
|
||||
|
||||
// Chargement initial des deux ressources (liste + referentiel des types).
|
||||
// Le referentiel est pre-charge ici (et pas dans le drawer) pour que le
|
||||
// select soit pret au moment ou l'utilisateur clique sur « + Ajouter ».
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
fetchTypes()
|
||||
})
|
||||
</script>
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Types front du module Catalog (M0 — Gestion des categories).
|
||||
*
|
||||
* Contrats API consommes :
|
||||
* - GET /api/categories → HydraCollection<Category>
|
||||
* - GET /api/categories/{id} → Category
|
||||
* - 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"]).
|
||||
* - `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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reference legere d'un user, telle qu'embarquee dans Category.createdBy /
|
||||
* updatedBy. Volontairement minimaliste : on n'a besoin que de l'identifiant
|
||||
* et de l'username pour l'affichage courant.
|
||||
*/
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference du referentiel CategoryType (lecture seule au M0).
|
||||
*/
|
||||
export interface CategoryType {
|
||||
id: number
|
||||
code: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorie metier — telle qu'elle est lue depuis l'API. L'entite porte le
|
||||
* pattern Timestampable+Blamable (cf. spec-back § 2.8).
|
||||
*/
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
/** 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
|
||||
updatedAt: string
|
||||
createdBy: User | null
|
||||
updatedBy: User | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
categoryTypes: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload accepte en PATCH /api/categories/{id}. Tous les champs sont
|
||||
* optionnels (modification partielle).
|
||||
*/
|
||||
export interface CategoryUpdateInput {
|
||||
name?: string
|
||||
categoryTypes?: string[]
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
<template>
|
||||
<div data-testid="geo-pin">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700">{{ t('commercial.geo.title') }}</span>
|
||||
<!-- Badge « a geolocaliser » : adresse valide mais sans coordonnees
|
||||
(spec M6 § 3.2 — exclue du calcul de tournee, RG-6.05). -->
|
||||
<span
|
||||
v-if="!hasCoords"
|
||||
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800"
|
||||
data-testid="geo-badge-missing"
|
||||
>
|
||||
{{ t('commercial.geo.toGeolocate') }}
|
||||
</span>
|
||||
<!-- Pin fige a la main (RG-6.08) : informatif. -->
|
||||
<span
|
||||
v-else-if="geoManual"
|
||||
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
|
||||
data-testid="geo-badge-manual"
|
||||
>
|
||||
{{ t('commercial.geo.manualPin') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Mini-carte Leaflet (exception documentee a @malio/layer-ui : carte
|
||||
interactive, type non couvert par la lib — cf. frontend.md
|
||||
§ Composants formulaires). TODO : migrer si la lib couvre un jour
|
||||
les cartes. -->
|
||||
<div
|
||||
v-if="hasCoords"
|
||||
ref="mapEl"
|
||||
class="h-48 w-full rounded border border-gray-200"
|
||||
data-testid="geo-map"
|
||||
/>
|
||||
<p v-if="hasCoords && !readonly" class="mt-1 text-xs text-gray-500">
|
||||
{{ t('commercial.geo.dragHint') }}
|
||||
</p>
|
||||
|
||||
<div v-if="!readonly" class="mt-2 flex items-center gap-4">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
:label="t('commercial.geo.regeocode')"
|
||||
:disabled="regeocoding || !canRegeocode"
|
||||
data-testid="geo-regeocode"
|
||||
@click="regeocode"
|
||||
/>
|
||||
<span v-if="regeocodeFailed" class="text-xs text-red-600" data-testid="geo-regeocode-failed">
|
||||
{{ t('commercial.geo.regeocodeFailed') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Map as LeafletMap, Marker } from 'leaflet'
|
||||
import { useAddressAutocomplete } from '~/shared/composables/useAddressAutocomplete'
|
||||
|
||||
/**
|
||||
* Mini-carte d'ajustement du pin d'une adresse Tiers (M6.1, spec § 8.3).
|
||||
*
|
||||
* - Marqueur deplacable : au drag, emet les coordonnees corrigees avec
|
||||
* geoManual = true (RG-6.08 : le geocodage auto ne reecrira plus). Le parent
|
||||
* met a jour le brouillon ; la persistance suit le submit du formulaire
|
||||
* (POST/PATCH de l'adresse), comme tous les champs du bloc.
|
||||
* - « Re-geocoder depuis l'adresse » : previsualise la position BAN cote front
|
||||
* et emet geoManual = false — au save, le back (BanGeocoder) refait autorite
|
||||
* et pose geocodedAt.
|
||||
* - Sans coordonnees : pas de carte, badge « a geolocaliser ».
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** Latitude WGS84 (chaine decimale) ou null si non geolocalisee. */
|
||||
latitude: string | null
|
||||
/** Longitude WGS84 (chaine decimale) ou null si non geolocalisee. */
|
||||
longitude: string | null
|
||||
/** RG-6.08 : pin deja corrige a la main. */
|
||||
geoManual: boolean
|
||||
/** Adresse postale a re-geocoder (« rue, code postal ville »). */
|
||||
geocodeQuery: string | null
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Nouveau positionnement du pin (drag manuel ou re-geocodage previsualise). */
|
||||
'update:coords': [value: { latitude: string, longitude: string, geoManual: boolean }]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const autocomplete = useAddressAutocomplete()
|
||||
|
||||
const mapEl = ref<HTMLElement | null>(null)
|
||||
const regeocoding = ref(false)
|
||||
const regeocodeFailed = ref(false)
|
||||
|
||||
const hasCoords = computed(() =>
|
||||
props.latitude !== null && props.latitude !== ''
|
||||
&& props.longitude !== null && props.longitude !== '',
|
||||
)
|
||||
|
||||
const canRegeocode = computed(() => (props.geocodeQuery ?? '').trim().length >= 3)
|
||||
|
||||
// Instances Leaflet (hors reactivite Vue : un proxy sur la Map casse Leaflet).
|
||||
let map: LeafletMap | null = null
|
||||
let marker: Marker | null = null
|
||||
|
||||
/** Zoom d'affichage du pin (niveau rue). */
|
||||
const PIN_ZOOM = 16
|
||||
|
||||
/**
|
||||
* Monte la carte Leaflet dans le conteneur (import dynamique : la lib n'est
|
||||
* chargee que si l'adresse a des coordonnees).
|
||||
*/
|
||||
async function ensureMap(): Promise<void> {
|
||||
if (map !== null || mapEl.value === null || !hasCoords.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const mod = await import('leaflet')
|
||||
const L = mod.default ?? mod
|
||||
await import('leaflet/dist/leaflet.css')
|
||||
|
||||
// Le conteneur peut avoir disparu pendant le chargement async (v-if).
|
||||
if (mapEl.value === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const position: [number, number] = [Number(props.latitude), Number(props.longitude)]
|
||||
|
||||
map = L.map(mapEl.value, { scrollWheelZoom: false }).setView(position, PIN_ZOOM)
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(map)
|
||||
|
||||
// divIcon SVG inline : evite les assets PNG de Leaflet (chemins casses par
|
||||
// le bundler Vite sans configuration dediee).
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="28" height="40" fill="#2563eb" stroke="#1e40af" stroke-width="0.5"><path d="M12 0C7 0 3 4 3 9c0 6.6 9 15 9 15s9-8.4 9-15c0-5-4-9-9-9zm0 12.5A3.5 3.5 0 1 1 12 5.5a3.5 3.5 0 0 1 0 7z"/></svg>',
|
||||
iconSize: [28, 40],
|
||||
iconAnchor: [14, 40],
|
||||
})
|
||||
|
||||
marker = L.marker(position, { icon, draggable: !props.readonly }).addTo(map)
|
||||
marker.on('dragend', onMarkerDragEnd)
|
||||
}
|
||||
|
||||
/** Drag du pin -> coordonnees corrigees + geoManual (RG-6.08). */
|
||||
function onMarkerDragEnd(): void {
|
||||
if (marker === null) {
|
||||
return
|
||||
}
|
||||
const position = marker.getLatLng()
|
||||
emit('update:coords', {
|
||||
latitude: position.lat.toFixed(7),
|
||||
longitude: position.lng.toFixed(7),
|
||||
geoManual: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* « Re-geocoder depuis l'adresse » : previsualisation BAN cote front. Emet
|
||||
* geoManual = false — le geocodage serveur refait autorite au save.
|
||||
*/
|
||||
async function regeocode(): Promise<void> {
|
||||
regeocodeFailed.value = false
|
||||
const query = (props.geocodeQuery ?? '').trim()
|
||||
if (query.length < 3) {
|
||||
regeocodeFailed.value = true
|
||||
return
|
||||
}
|
||||
|
||||
regeocoding.value = true
|
||||
try {
|
||||
const coords = await autocomplete.geocode(query)
|
||||
if (coords === null) {
|
||||
regeocodeFailed.value = true
|
||||
return
|
||||
}
|
||||
emit('update:coords', { ...coords, geoManual: false })
|
||||
}
|
||||
catch {
|
||||
// BAN indisponible : position inchangee, message inline.
|
||||
regeocodeFailed.value = true
|
||||
}
|
||||
finally {
|
||||
regeocoding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Coordonnees modifiees par le parent (drag deja applique, re-geocodage,
|
||||
// rechargement) : recale le marqueur, ou monte la carte si elle n'existe pas
|
||||
// encore (premieres coordonnees d'une adresse « a geolocaliser »).
|
||||
watch(
|
||||
() => [props.latitude, props.longitude] as const,
|
||||
async () => {
|
||||
if (!hasCoords.value) {
|
||||
return
|
||||
}
|
||||
if (map === null) {
|
||||
await nextTick()
|
||||
await ensureMap()
|
||||
return
|
||||
}
|
||||
const position: [number, number] = [Number(props.latitude), Number(props.longitude)]
|
||||
marker?.setLatLng(position)
|
||||
map.panTo(position)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(ensureMap)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
map?.remove()
|
||||
map = null
|
||||
marker = null
|
||||
})
|
||||
</script>
|
||||
@@ -1,403 +0,0 @@
|
||||
<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')"
|
||||
/>
|
||||
|
||||
<!-- 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"
|
||||
:required="true"
|
||||
: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"
|
||||
:required="true"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('commercial.clients.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
@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="true"
|
||||
:readonly="readonly"
|
||||
: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"
|
||||
/>
|
||||
<div v-else aria-hidden="true" />
|
||||
|
||||
<MalioInputEmail
|
||||
v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail"
|
||||
:model-value="model.billingEmailSecondary"
|
||||
:label="t('commercial.clients.form.address.billingEmailSecondary')"
|
||||
:readonly="readonly"
|
||||
: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"
|
||||
:required="true"
|
||||
: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"
|
||||
:required="true"
|
||||
@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"
|
||||
:required="true"
|
||||
: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"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="errors?.city"
|
||||
@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"
|
||||
:required="true"
|
||||
: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"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('commercial.clients.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
: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"
|
||||
:required="true"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('commercial.clients.form.address.streetComplement')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Pin geographique de l'adresse (M6.1, spec § 8.3) : mini-carte avec
|
||||
marqueur ajustable, persiste au submit comme le reste du bloc. -->
|
||||
<div class="col-span-4">
|
||||
<AddressGeoPin
|
||||
:latitude="model.latitude"
|
||||
:longitude="model.longitude"
|
||||
:geo-manual="model.geoManual"
|
||||
:geocode-query="geocodeQuery"
|
||||
:readonly="readonly"
|
||||
@update:coords="onCoordsUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
addressFlagsFromType,
|
||||
addressTypeFromFlags,
|
||||
isBillingEmailRequired,
|
||||
type AddressType,
|
||||
} from '~/modules/commercial/utils/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'
|
||||
|
||||
// Masque code postal FR : 5 chiffres.
|
||||
const POSTAL_CODE_MASK = '#####'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Brouillon de l'adresse (v-model). */
|
||||
modelValue: AddressFormDraft
|
||||
title: string
|
||||
/** Categories autorisees sur une adresse (DISTRIBUTEUR/COURTIER exclus, RG-1.29). */
|
||||
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
|
||||
readonly?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: AddressFormDraft]
|
||||
'remove': []
|
||||
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
|
||||
'degraded': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const autocomplete = useAddressAutocomplete()
|
||||
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
// 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[]>([])
|
||||
// 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)
|
||||
// afficherait un champ vide en lecture seule (consultation 1.11) ou en edition
|
||||
// d'une adresse existante (1.12), ou la BAN n'a pas (re)peuple les suggestions.
|
||||
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 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[] = []
|
||||
|
||||
/** 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 })
|
||||
}
|
||||
|
||||
// Adresse postale a re-geocoder (« rue, code postal ville ») — miroir du
|
||||
// getDisplayLabel() serveur (le complement bruite le geocodage, exclu).
|
||||
const geocodeQuery = computed<string | null>(() => {
|
||||
const locality = [model.value.postalCode, model.value.city].filter(Boolean).join(' ')
|
||||
const parts = [model.value.street, locality].filter(part => part && String(part).trim() !== '')
|
||||
return parts.length > 0 ? parts.join(', ') : null
|
||||
})
|
||||
|
||||
/** Pin deplace / re-geocode : repercute coordonnees + drapeau manuel (RG-6.08). */
|
||||
function onCoordsUpdate(coords: { latitude: string, longitude: string, geoManual: boolean }): void {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
geoManual: coords.geoManual,
|
||||
})
|
||||
}
|
||||
|
||||
/** Revele le 2e champ email de facturation (clic sur le « + »). */
|
||||
function revealSecondaryBillingEmail(): void {
|
||||
emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: 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)
|
||||
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
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 {
|
||||
// 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> {
|
||||
// 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
|
||||
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
|
||||
// (pas de bascule definitive — c'etait le bug). Avertissement une seule fois.
|
||||
banAddressOptions.value = []
|
||||
notifyUnavailable()
|
||||
}
|
||||
finally {
|
||||
addressLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection d'une suggestion d'adresse → remplit rue + ville + CP.
|
||||
* Le type d'option suit le contrat MalioInputAutocomplete ({ label, value }).
|
||||
*/
|
||||
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>
|
||||
@@ -1,111 +0,0 @@
|
||||
<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')"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.lastName"
|
||||
:label="t('commercial.clients.form.contact.lastName')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="model.firstName"
|
||||
:label="t('commercial.clients.form.contact.firstName')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('commercial.clients.form.contact.jobTitle')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
:model-value="model.email"
|
||||
:label="t('commercial.clients.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@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"
|
||||
: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"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('commercial.clients.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ContactFormDraft } from '~/modules/commercial/types/clientForm'
|
||||
|
||||
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste
|
||||
// serveur, cf. formatPhoneFR re-applique a la valeur renvoyee).
|
||||
const PHONE_MASK = '## ## ## ## ##'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Brouillon du contact (v-model). */
|
||||
modelValue: ContactFormDraft
|
||||
/** Titre du bloc (ex: « Contact 1 »). */
|
||||
title: string
|
||||
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-1.14). */
|
||||
removable?: boolean
|
||||
/** Bloc en lecture seule (onglet valide). */
|
||||
readonly?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ContactFormDraft]
|
||||
'remove': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Alias local pour la lisibilite du template.
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
/** 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 })
|
||||
}
|
||||
|
||||
/** Revele le 2e numero (RG-1.02/1.20 : max 1 secondaire, le « + » disparait). */
|
||||
function revealSecondaryPhone(): void {
|
||||
emit('update:modelValue', { ...props.modelValue, hasSecondaryPhone: true })
|
||||
}
|
||||
</script>
|
||||
@@ -1,345 +0,0 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : modal de confirmation cote parent. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.address.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<!-- 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"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
: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"
|
||||
:required="true"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('commercial.suppliers.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client
|
||||
porte ici l'email de facturation, absent cote fournisseur). -->
|
||||
<div aria-hidden="true" />
|
||||
|
||||
<!-- 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"
|
||||
:required="true"
|
||||
: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"
|
||||
:required="true"
|
||||
@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"
|
||||
:required="true"
|
||||
: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"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.city"
|
||||
:label="t('commercial.suppliers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
: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"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('commercial.suppliers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
: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')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('commercial.suppliers.form.address.streetComplement')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bennes : stepper (specifique fournisseur, defaut 0). -->
|
||||
<MalioInputNumber
|
||||
:model-value="model.bennes"
|
||||
:label="t('commercial.suppliers.form.address.bennes')"
|
||||
:min="0"
|
||||
:readonly="readonly"
|
||||
:error="errors?.bennes"
|
||||
@update:model-value="(v: string) => update('bennes', v)"
|
||||
/>
|
||||
|
||||
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur). -->
|
||||
<MalioCheckbox
|
||||
id="address-triage-provider"
|
||||
:label="t('commercial.suppliers.form.address.triageProvider')"
|
||||
:model-value="model.triageProvider"
|
||||
group-class="self-center"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: boolean) => update('triageProvider', v)"
|
||||
/>
|
||||
|
||||
<!-- Pin geographique de l'adresse (M6.1, spec § 8.3) : mini-carte avec
|
||||
marqueur ajustable, persiste au submit comme le reste du bloc. -->
|
||||
<div class="col-span-4">
|
||||
<AddressGeoPin
|
||||
:latitude="model.latitude"
|
||||
:longitude="model.longitude"
|
||||
:geo-manual="model.geoManual"
|
||||
:geocode-query="geocodeQuery"
|
||||
:readonly="readonly"
|
||||
@update:coords="onCoordsUpdate"
|
||||
/>
|
||||
</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'
|
||||
|
||||
// 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
|
||||
readonly?: 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[] = []
|
||||
|
||||
/** 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 })
|
||||
}
|
||||
|
||||
// Adresse postale a re-geocoder (« rue, code postal ville ») — miroir du
|
||||
// getDisplayLabel() serveur (le complement bruite le geocodage, exclu).
|
||||
const geocodeQuery = computed<string | null>(() => {
|
||||
const locality = [model.value.postalCode, model.value.city].filter(Boolean).join(' ')
|
||||
const parts = [model.value.street, locality].filter(part => part && String(part).trim() !== '')
|
||||
return parts.length > 0 ? parts.join(', ') : null
|
||||
})
|
||||
|
||||
/** Pin deplace / re-geocode : repercute coordonnees + drapeau manuel (RG-6.08). */
|
||||
function onCoordsUpdate(coords: { latitude: string, longitude: string, geoManual: boolean }): void {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
geoManual: coords.geoManual,
|
||||
})
|
||||
}
|
||||
|
||||
/** 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)
|
||||
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
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>
|
||||
@@ -1,109 +0,0 @@
|
||||
<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, RG-2.13) ou en lecture seule. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.lastName"
|
||||
:label="t('commercial.suppliers.form.contact.lastName')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="model.firstName"
|
||||
:label="t('commercial.suppliers.form.contact.firstName')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('commercial.suppliers.form.contact.jobTitle')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
:model-value="model.email"
|
||||
:label="t('commercial.suppliers.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('commercial.suppliers.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
: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"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('commercial.suppliers.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SupplierContactFormDraft } from '~/modules/commercial/types/supplierForm'
|
||||
|
||||
// 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
|
||||
/** Bloc en lecture seule (onglet valide). */
|
||||
readonly?: 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)
|
||||
|
||||
/** 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,151 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import AddressGeoPin from '../AddressGeoPin.vue'
|
||||
|
||||
// Mock Leaflet (hoisted) : capture le handler `dragend` et pilote la position
|
||||
// renvoyee par getLatLng — permet de simuler un drag du marqueur sans DOM reel.
|
||||
const leafletState = vi.hoisted(() => ({
|
||||
dragendHandler: null as (() => void) | null,
|
||||
markerPosition: { lat: 0, lng: 0 },
|
||||
}))
|
||||
|
||||
vi.mock('leaflet', () => {
|
||||
const marker = {
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
on: vi.fn((event: string, handler: () => void) => {
|
||||
if (event === 'dragend') {
|
||||
leafletState.dragendHandler = handler
|
||||
}
|
||||
}),
|
||||
getLatLng: vi.fn(() => leafletState.markerPosition),
|
||||
setLatLng: vi.fn(),
|
||||
}
|
||||
const map = {
|
||||
setView: vi.fn().mockReturnThis(),
|
||||
panTo: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}
|
||||
const L = {
|
||||
map: vi.fn(() => map),
|
||||
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
|
||||
divIcon: vi.fn(() => ({})),
|
||||
marker: vi.fn(() => marker),
|
||||
}
|
||||
return { default: L, ...L }
|
||||
})
|
||||
vi.mock('leaflet/dist/leaflet.css', () => ({ default: {} }))
|
||||
|
||||
// Mock controlable du geocodage BAN (bouton « Re-geocoder »).
|
||||
const { geocodeMock } = vi.hoisted(() => ({ geocodeMock: vi.fn() }))
|
||||
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
|
||||
useAddressAutocomplete: () => ({ geocode: geocodeMock }),
|
||||
}))
|
||||
|
||||
// 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)
|
||||
vi.stubGlobal('watch', watch)
|
||||
vi.stubGlobal('nextTick', nextTick)
|
||||
vi.stubGlobal('onMounted', onMounted)
|
||||
vi.stubGlobal('onBeforeUnmount', onBeforeUnmount)
|
||||
|
||||
interface PinProps {
|
||||
latitude?: string | null
|
||||
longitude?: string | null
|
||||
geoManual?: boolean
|
||||
geocodeQuery?: string | null
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
function mountPin(props: PinProps = {}) {
|
||||
return mount(AddressGeoPin, {
|
||||
props: {
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
geoManual: false,
|
||||
geocodeQuery: '1 rue du Test, 86100 Châtellerault',
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: { MalioButton: true },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
leafletState.dragendHandler = null
|
||||
geocodeMock.mockReset()
|
||||
})
|
||||
|
||||
describe('AddressGeoPin — adresse sans coordonnees', () => {
|
||||
it('affiche le badge « a geolocaliser » et aucune carte', () => {
|
||||
const wrapper = mountPin()
|
||||
|
||||
expect(wrapper.find('[data-testid="geo-badge-missing"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="geo-map"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AddressGeoPin — drag du marqueur (RG-6.08)', () => {
|
||||
it('emet les coordonnees corrigees avec geoManual=true au dragend', async () => {
|
||||
const wrapper = mountPin({ latitude: '46.5802596', longitude: '0.3404333' })
|
||||
await flushPromises() // import dynamique de Leaflet + montage carte
|
||||
|
||||
expect(leafletState.dragendHandler).not.toBeNull()
|
||||
|
||||
// L'utilisateur depose le pin ailleurs (lieu-dit mal geocode).
|
||||
leafletState.markerPosition = { lat: 48.1234567, lng: -1.6543217 }
|
||||
leafletState.dragendHandler?.()
|
||||
|
||||
const emitted = wrapper.emitted('update:coords')
|
||||
expect(emitted).toHaveLength(1)
|
||||
expect(emitted?.[0]?.[0]).toEqual({
|
||||
latitude: '48.1234567',
|
||||
longitude: '-1.6543217',
|
||||
geoManual: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('affiche le badge « pin manuel » quand geoManual est vrai', () => {
|
||||
const wrapper = mountPin({ latitude: '46.58', longitude: '0.34', geoManual: true })
|
||||
|
||||
expect(wrapper.find('[data-testid="geo-badge-manual"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="geo-badge-missing"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AddressGeoPin — re-geocodage depuis l\'adresse', () => {
|
||||
it('emet la position BAN avec geoManual=false (le back refera autorite au save)', async () => {
|
||||
geocodeMock.mockResolvedValueOnce({ latitude: '46.5802596', longitude: '0.3404333' })
|
||||
const wrapper = mountPin()
|
||||
|
||||
await wrapper.find('[data-testid="geo-regeocode"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(geocodeMock).toHaveBeenCalledWith('1 rue du Test, 86100 Châtellerault')
|
||||
expect(wrapper.emitted('update:coords')?.[0]?.[0]).toEqual({
|
||||
latitude: '46.5802596',
|
||||
longitude: '0.3404333',
|
||||
geoManual: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('signale l\'echec sans emettre quand la BAN ne trouve rien', async () => {
|
||||
geocodeMock.mockResolvedValueOnce(null)
|
||||
const wrapper = mountPin()
|
||||
|
||||
await wrapper.find('[data-testid="geo-regeocode"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('update:coords')).toBeUndefined()
|
||||
expect(wrapper.find('[data-testid="geo-regeocode-failed"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('masque le bouton en lecture seule', () => {
|
||||
const wrapper = mountPin({ readonly: true })
|
||||
|
||||
expect(wrapper.find('[data-testid="geo-regeocode"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,230 +0,0 @@
|
||||
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,
|
||||
// Pin geographique (M6.1) : teste dans AddressGeoPin.spec.ts.
|
||||
AddressGeoPin: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
// Pin geographique (M6.1) : teste dans AddressGeoPin.spec.ts.
|
||||
AddressGeoPin: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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.')
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
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('')
|
||||
})
|
||||
})
|
||||
@@ -1,178 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
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('')
|
||||
})
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
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 { useClient } = await import('../useClient')
|
||||
|
||||
const SAMPLE = { '@id': '/api/clients/42', id: 42, companyName: 'ACME', isArchived: false }
|
||||
|
||||
describe('useClient', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
mockPatch.mockReset()
|
||||
mockGet.mockResolvedValue(SAMPLE)
|
||||
mockPatch.mockResolvedValue({ ...SAMPLE, isArchived: true })
|
||||
})
|
||||
|
||||
it('charge le detail via GET /clients/{id} en Hydra, sans toast', async () => {
|
||||
const { client, load } = useClient(42)
|
||||
await load()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/clients/42',
|
||||
{},
|
||||
expect.objectContaining({
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
}),
|
||||
)
|
||||
expect(client.value).toEqual(SAMPLE)
|
||||
})
|
||||
|
||||
it('bascule loading pendant le chargement et le retombe a false', async () => {
|
||||
const { loading, load } = useClient(42)
|
||||
const promise = load()
|
||||
expect(loading.value).toBe(true)
|
||||
await promise
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('marque error et laisse client null si le GET echoue (404...)', async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error('not found'))
|
||||
const { client, error, load } = useClient(99)
|
||||
await load()
|
||||
expect(error.value).toBe(true)
|
||||
expect(client.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 { client, load, archive } = useClient(42)
|
||||
await load()
|
||||
await archive()
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/clients/42',
|
||||
{ isArchived: true },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
// Le detail est re-fetch (le PATCH ne renvoie pas l'embed complet).
|
||||
expect(mockGet).toHaveBeenCalledTimes(2)
|
||||
expect(client.value?.isArchived).toBe(true)
|
||||
})
|
||||
|
||||
it('restore() PATCHe { isArchived: false } (payload isArchived SEUL)', async () => {
|
||||
const { load, restore } = useClient(42)
|
||||
await load()
|
||||
await restore()
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/clients/42',
|
||||
{ isArchived: false },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('propage l\'erreur (ex: 409 conflit homonyme RG-1.23) au lieu de l\'avaler', async () => {
|
||||
const conflict = { response: { status: 409 } }
|
||||
mockPatch.mockRejectedValueOnce(conflict)
|
||||
const { load, restore } = useClient(42)
|
||||
await load()
|
||||
await expect(restore()).rejects.toBe(conflict)
|
||||
})
|
||||
})
|
||||
@@ -1,122 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,80 +0,0 @@
|
||||
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 simuler un endpoint en echec
|
||||
// (ex: 403 sur /categories pour un role sans la permission de lecture).
|
||||
// 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 { useClientReferentials } = await import('../useClientReferentials')
|
||||
|
||||
describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
})
|
||||
|
||||
it('un referentiel en echec (403) ne vide QUE son select, pas les autres', async () => {
|
||||
// /categories rejette (simulateur d'un 403), tous les autres repondent.
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/categories') {
|
||||
return Promise.reject(new Error('403 Forbidden'))
|
||||
}
|
||||
if (url === '/sites') {
|
||||
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' }],
|
||||
})
|
||||
})
|
||||
|
||||
const refs = useClientReferentials()
|
||||
// loadCommon ne doit JAMAIS rejeter : l'echec d'un referentiel est isole.
|
||||
await refs.loadCommon()
|
||||
|
||||
// Resilience : les referentiels OK sont peuples malgre l'echec de /categories.
|
||||
// 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([])
|
||||
})
|
||||
|
||||
it('charge tous les referentiels quand tout repond', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/categories') {
|
||||
return Promise.resolve({
|
||||
member: [{ '@id': '/api/categories/1', code: 'SECTEUR', name: 'Secteur' }],
|
||||
})
|
||||
}
|
||||
if (url === '/sites') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
|
||||
}
|
||||
return Promise.resolve({ member: [] })
|
||||
})
|
||||
|
||||
const refs = useClientReferentials()
|
||||
await refs.loadCommon()
|
||||
|
||||
expect(refs.categories.value).toEqual([
|
||||
{ value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' },
|
||||
])
|
||||
// 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' }])
|
||||
})
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { HydraCollection } from '~/shared/utils/api'
|
||||
import type { Client } from '../useClientsRepository'
|
||||
|
||||
// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter
|
||||
// les appels declenches par usePaginatedList (que useClientsRepository enveloppe)
|
||||
// et controler les reponses. Meme pattern que useCategoriesAdmin.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 { useClientsRepository } = await import('../useClientsRepository')
|
||||
|
||||
/** Envelope Hydra minimale (la liste reelle des membres importe peu ici). */
|
||||
function makeHydra(total: number): HydraCollection<Client> {
|
||||
return { totalItems: total, member: [] }
|
||||
}
|
||||
|
||||
describe('useClientsRepository', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
|
||||
mockGet.mockResolvedValue(makeHydra(25))
|
||||
})
|
||||
|
||||
it('cible la ressource /clients en page 1 par defaut', async () => {
|
||||
const repo = useClientsRepository()
|
||||
await repo.fetch()
|
||||
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/clients',
|
||||
{ page: 1, itemsPerPage: 10 },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('pousse les filtres du drawer (categories multi, sites, archives) et retombe en page 1', async () => {
|
||||
const repo = useClientsRepository()
|
||||
await repo.fetch()
|
||||
await repo.goToPage(2)
|
||||
expect(repo.currentPage.value).toBe(2)
|
||||
|
||||
await repo.setFilters(
|
||||
{
|
||||
search: 'acme',
|
||||
'categoryCode[]': ['DISTRIBUTEUR', 'COURTIER'],
|
||||
'siteId[]': ['1', '2'],
|
||||
archivedOnly: true,
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
|
||||
expect(repo.currentPage.value).toBe(1)
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/clients',
|
||||
{
|
||||
search: 'acme',
|
||||
'categoryCode[]': ['DISTRIBUTEUR', 'COURTIER'],
|
||||
'siteId[]': ['1', '2'],
|
||||
archivedOnly: true,
|
||||
page: 1,
|
||||
itemsPerPage: 10,
|
||||
},
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('repasse a une query propre apres reinitialisation des filtres', async () => {
|
||||
const repo = useClientsRepository()
|
||||
await repo.setFilters({ search: 'acme', archivedOnly: true }, { replace: true })
|
||||
await repo.setFilters({}, { replace: true })
|
||||
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/clients',
|
||||
{ page: 1, itemsPerPage: 10 },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
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('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' }])
|
||||
})
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
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()
|
||||
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
|
||||
mockGet.mockResolvedValue(makeHydra(25))
|
||||
})
|
||||
|
||||
it('cible la ressource /suppliers en page 1 par defaut', async () => {
|
||||
const repo = useSuppliersRepository()
|
||||
await repo.fetch()
|
||||
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/suppliers',
|
||||
{ page: 1, itemsPerPage: 10 },
|
||||
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'],
|
||||
includeArchived: true,
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
|
||||
expect(repo.currentPage.value).toBe(1)
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/suppliers',
|
||||
{
|
||||
search: 'acme',
|
||||
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
|
||||
'siteId[]': ['86', '17'],
|
||||
includeArchived: true,
|
||||
page: 1,
|
||||
itemsPerPage: 10,
|
||||
},
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('repasse a une query propre apres reinitialisation des filtres', async () => {
|
||||
const repo = useSuppliersRepository()
|
||||
await repo.setFilters({ search: 'acme', includeArchived: true }, { replace: true })
|
||||
await repo.setFilters({}, { replace: true })
|
||||
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/suppliers',
|
||||
{ page: 1, itemsPerPage: 10 },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation'
|
||||
|
||||
/**
|
||||
* Chargement et actions d'archivage d'un client unique (ecran « Consultation
|
||||
* client », 1.11). Lit le detail embarque via `GET /api/clients/{id}` (contacts /
|
||||
* adresses / ribs sous `client:item:read` / `client: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 RG-1.23 : homonyme actif a la
|
||||
* restauration) sont PROPAGEES a l'appelant, qui decide du toast a afficher.
|
||||
*/
|
||||
export function useClient(id: number | string) {
|
||||
const api = useApi()
|
||||
|
||||
const client = ref<ClientDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
|
||||
/** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
|
||||
function fetchDetail(): Promise<ClientDetail> {
|
||||
return api.get<ClientDetail>(
|
||||
`/clients/${id}`,
|
||||
{},
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
}
|
||||
|
||||
/** Charge le detail du client. En cas d'echec : `error = true`, `client = null`. */
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
client.value = await fetchDetail()
|
||||
}
|
||||
catch {
|
||||
error.value = true
|
||||
client.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
|
||||
* `client: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, RG-1.23)
|
||||
* est propagee a l'appelant AVANT le rechargement.
|
||||
*/
|
||||
async function setArchived(isArchived: boolean): Promise<void> {
|
||||
await api.patch(`/clients/${id}`, { isArchived }, { toast: false })
|
||||
client.value = await fetchDetail()
|
||||
}
|
||||
|
||||
return {
|
||||
client,
|
||||
loading,
|
||||
error,
|
||||
load,
|
||||
archive: () => setArchived(true),
|
||||
restore: () => setArchived(false),
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
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, 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
|
||||
* 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`)
|
||||
* pour pouvoir etre renvoyees telles quelles dans les payloads POST/PATCH
|
||||
* (relations ManyToOne / ManyToMany).
|
||||
*
|
||||
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
|
||||
*/
|
||||
|
||||
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox ({ label, value }). */
|
||||
export interface RefOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Option de type de reglement enrichie de son code stable (RG-1.12 / RG-1.13). */
|
||||
export interface PaymentTypeOption extends RefOption {
|
||||
code: string
|
||||
}
|
||||
|
||||
/** Option de categorie enrichie de son code stable (filtrage RG-1.29 cote adresse). */
|
||||
export interface CategoryOption extends RefOption {
|
||||
code: string
|
||||
}
|
||||
|
||||
/** Option de client (distributeur / courtier) — value = IRI du client lie. */
|
||||
export type ClientOption = RefOption
|
||||
|
||||
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 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[]>([])
|
||||
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[]>([])
|
||||
|
||||
/** 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 (hors distributeurs/courtiers,
|
||||
* charges a la demande selon la relation choisie).
|
||||
*
|
||||
* 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.clients.view` (donc /tva_modes, /banks...
|
||||
* accessibles) mais PAS `catalog.categories.view` ni `sites.view` : sans
|
||||
* isolation, le 403 sur /categories ferait echouer tout le bloc et viderait
|
||||
* les selects comptables dont Compta a besoin sur l'ecran de modification.
|
||||
* Un referentiel en echec reste simplement vide (l'ecran d'edition complete
|
||||
* l'affichage des valeurs courantes depuis l'embed du detail client).
|
||||
*/
|
||||
async function loadCommon(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
// 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 })) }),
|
||||
fetchAll<SiteMember>('/sites')
|
||||
// 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')
|
||||
.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.
|
||||
fetchAll<CountryMember>('/countries')
|
||||
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
|
||||
])
|
||||
}
|
||||
|
||||
/** Liste des clients pouvant etre choisis comme distributeur (code DISTRIBUTEUR). */
|
||||
async function loadDistributors(): Promise<void> {
|
||||
if (distributors.value.length > 0) {
|
||||
return
|
||||
}
|
||||
const clients = await fetchAll<ClientMember>('/clients', { categoryCode: 'DISTRIBUTEUR' })
|
||||
distributors.value = clients.map(c => ({ value: c['@id'], label: c.companyName }))
|
||||
}
|
||||
|
||||
/** Liste des clients pouvant etre choisis comme courtier (code COURTIER). */
|
||||
async function loadBrokers(): Promise<void> {
|
||||
if (brokers.value.length > 0) {
|
||||
return
|
||||
}
|
||||
const clients = await fetchAll<ClientMember>('/clients', { categoryCode: 'COURTIER' })
|
||||
brokers.value = clients.map(c => ({ value: c['@id'], label: c.companyName }))
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
sites,
|
||||
tvaModes,
|
||||
paymentDelays,
|
||||
paymentTypes,
|
||||
banks,
|
||||
countries,
|
||||
distributors,
|
||||
brokers,
|
||||
loadCommon,
|
||||
loadDistributors,
|
||||
loadBrokers,
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||
|
||||
/**
|
||||
* Site Starseed rattache a une adresse du client, tel qu'embarque en LISTE
|
||||
* (groupe site:read) pour la colonne « Site(s) » du Repertoire (badges colores).
|
||||
*/
|
||||
export interface ClientSite {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorie rattachee au client, embarquee en LISTE (groupe category:read).
|
||||
* Seul le `code` (stable, MAJUSCULE — ERP-78) est affiche dans la colonne
|
||||
* « Catégories ». Les autres champs sont presents mais non utilises ici.
|
||||
*/
|
||||
export interface ClientCategory {
|
||||
code: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue MINIMALE d'un client 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-62).
|
||||
*/
|
||||
export interface Client {
|
||||
id: number
|
||||
companyName: string
|
||||
categories: ClientCategory[]
|
||||
sites: ClientSite[]
|
||||
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
|
||||
updatedAt: string | null
|
||||
isArchived: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Repertoire clients (ERP-62) — simple enveloppe de `usePaginatedList<Client>`
|
||||
* sur la ressource `/clients` (RG-13 : pagination serveur obligatoire ; jamais
|
||||
* de chargement integral en memoire).
|
||||
*
|
||||
* Les filtres (recherche, categories, sites, archives) sont pilotes par la page
|
||||
* via `setFilters` du composable partage — la remise en page 1 est garantie.
|
||||
*
|
||||
* 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` (cf. sites.vue / categories.vue). Aucun reset au logout a
|
||||
* gerer.
|
||||
*/
|
||||
export function useClientsRepository() {
|
||||
return usePaginatedList<Client>({ url: '/clients' })
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import type { SupplierDetail } from '~/modules/commercial/utils/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),
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
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[]>([])
|
||||
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 })) }),
|
||||
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,
|
||||
sites,
|
||||
tvaModes,
|
||||
paymentDelays,
|
||||
paymentTypes,
|
||||
banks,
|
||||
countries,
|
||||
loadCommon,
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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, inclusion des archives) sont pilotes
|
||||
* par la page via `setFilters` du composable partage — la remise en page 1 est
|
||||
* garantie.
|
||||
*
|
||||
* 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() {
|
||||
return usePaginatedList<Supplier>({ url: '/suppliers' })
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
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 « Inclure les archivés » dans setFilters sans toucher l\'URL', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
|
||||
// Coche « Inclure les archivés » puis applique les filtres.
|
||||
await wrapper.find('input[data-id="filter-include-archived"]').setValue(true)
|
||||
await wrapper.find('[data-label="commercial.suppliers.filters.apply"]').trigger('click')
|
||||
|
||||
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||
{ includeArchived: 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-include-archived"]').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,493 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete : retour repertoire + nom du client + actions (Modifier / Archiver|Restaurer). -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<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">
|
||||
<MalioButton
|
||||
v-if="canEdit"
|
||||
variant="secondary"
|
||||
icon-name="mdi:pencil-outline"
|
||||
icon-position="left"
|
||||
:label="t('commercial.clients.action.edit')"
|
||||
@click="goEdit"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showArchive"
|
||||
variant="secondary"
|
||||
icon-name="mdi:archive-arrow-down-outline"
|
||||
icon-position="left"
|
||||
:label="t('commercial.clients.action.archive')"
|
||||
@click="askToggleArchive"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showRestore"
|
||||
variant="secondary"
|
||||
icon-name="mdi:archive-arrow-up-outline"
|
||||
icon-position="left"
|
||||
:label="t('commercial.clients.action.restore')"
|
||||
@click="askToggleArchive"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Etats de chargement / introuvable. -->
|
||||
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('commercial.clients.consultation.loading') }}</p>
|
||||
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('commercial.clients.consultation.notFound') }}</p>
|
||||
|
||||
<template v-else-if="client">
|
||||
<!-- ── Formulaire principal (lecture seule) ──────────────────────── -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
:model-value="client.companyName"
|
||||
:label="t('commercial.clients.form.main.companyName')"
|
||||
readonly
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="categoryIris"
|
||||
:options="mainCategoryOptions"
|
||||
:label="t('commercial.clients.form.main.categories')"
|
||||
:display-tag="true"
|
||||
readonly
|
||||
/>
|
||||
<!-- Relation toujours affichee (vide = « Aucun »), comme en edition. -->
|
||||
<MalioSelect
|
||||
:model-value="relation.type"
|
||||
:options="relationOptions"
|
||||
:label="t('commercial.clients.form.main.relation')"
|
||||
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||
readonly
|
||||
/>
|
||||
<!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant,
|
||||
aucune valeur sans relation — meme comportement qu'en edition). -->
|
||||
<MalioInputText
|
||||
v-if="relation.type"
|
||||
:model-value="relation.name"
|
||||
:label="relation.type === 'distributeur' ? t('commercial.clients.form.main.distributorName') : t('commercial.clients.form.main.brokerName')"
|
||||
readonly
|
||||
/>
|
||||
<MalioCheckbox
|
||||
:model-value="client.triageService === true"
|
||||
:label="t('commercial.clients.form.main.triageService')"
|
||||
group-class="self-center"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||
<!-- Onglet Information -->
|
||||
<template #information>
|
||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
|
||||
sur les inputs (champ 40px centre dans un h-12 -> ~4px de
|
||||
coussin de chaque cote). -->
|
||||
<MalioInputTextArea
|
||||
:model-value="information.description"
|
||||
:label="t('commercial.clients.form.information.description')"
|
||||
resize="none"
|
||||
group-class="row-span-2 pt-1 pb-1"
|
||||
text-input="h-full text-lg"
|
||||
readonly
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="information.competitors"
|
||||
:label="t('commercial.clients.form.information.competitors')"
|
||||
readonly
|
||||
/>
|
||||
<MalioDate
|
||||
:model-value="information.foundedAt"
|
||||
:label="t('commercial.clients.form.information.foundedAt')"
|
||||
readonly
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="information.employeesCount"
|
||||
:label="t('commercial.clients.form.information.employeesCount')"
|
||||
readonly
|
||||
/>
|
||||
<MalioInputAmount
|
||||
:model-value="information.revenueAmount"
|
||||
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||
readonly
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="information.directorName"
|
||||
:label="t('commercial.clients.form.information.directorName')"
|
||||
readonly
|
||||
/>
|
||||
<MalioInputAmount
|
||||
:model-value="information.profitAmount"
|
||||
:label="t('commercial.clients.form.information.profitAmount')"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Contact -->
|
||||
<template #contact>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ClientContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="contact.id ?? index"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Adresse -->
|
||||
<template #address>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ClientAddressBlock
|
||||
v-for="(view, index) in addressViews"
|
||||
:key="view.draft.id ?? index"
|
||||
:model-value="view.draft"
|
||||
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
||||
:category-options="view.categoryOptions"
|
||||
:site-options="allSiteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
readonly
|
||||
/>
|
||||
</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-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
:model-value="accounting.siren"
|
||||
:label="t('commercial.clients.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
readonly
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="accounting.accountNumber"
|
||||
:label="t('commercial.clients.form.accounting.accountNumber')"
|
||||
readonly
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="tvaModeOptions"
|
||||
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="accounting.nTva"
|
||||
:label="t('commercial.clients.form.accounting.nTva')"
|
||||
readonly
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="paymentDelayOptions"
|
||||
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="paymentTypeOptions"
|
||||
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="accounting.bankIri"
|
||||
:model-value="accounting.bankIri"
|
||||
:options="bankOptions"
|
||||
:label="t('commercial.clients.form.accounting.bank')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB (0..n), lecture seule. -->
|
||||
<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)]"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
:model-value="rib.label"
|
||||
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||
readonly
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="rib.bic"
|
||||
:label="t('commercial.clients.form.accounting.ribBic')"
|
||||
readonly
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="rib.iban"
|
||||
:label="t('commercial.clients.form.accounting.ribIban')"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
|
||||
<template #transport><ComingSoonPlaceholder /></template>
|
||||
<template #statistics><ComingSoonPlaceholder /></template>
|
||||
<template #reports><ComingSoonPlaceholder /></template>
|
||||
<template #exchanges><ComingSoonPlaceholder /></template>
|
||||
</MalioTabList>
|
||||
</template>
|
||||
|
||||
<!-- Modal de confirmation Archiver / Restaurer. -->
|
||||
<MalioModal v-model="confirmOpen" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">
|
||||
{{ isArchived ? t('commercial.clients.consultation.confirmRestore.title') : t('commercial.clients.consultation.confirmArchive.title') }}
|
||||
</h2>
|
||||
</template>
|
||||
<p>{{ isArchived ? t('commercial.clients.consultation.confirmRestore.message') : t('commercial.clients.consultation.confirmArchive.message') }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('commercial.clients.form.confirmDelete.cancel')"
|
||||
@click="confirmOpen = false"
|
||||
/>
|
||||
<MalioButton
|
||||
:variant="isArchived ? 'primary' : 'danger'"
|
||||
button-class="flex-1"
|
||||
:label="t('commercial.clients.form.confirmDelete.confirm')"
|
||||
:disabled="toggling"
|
||||
@click="confirmToggleArchive"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useClient } from '~/modules/commercial/composables/useClient'
|
||||
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/clientFormRules'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
import {
|
||||
canEditClient,
|
||||
categoryOptionsOf,
|
||||
contactOptionsOf,
|
||||
mapAccountingDraft,
|
||||
mapAddressView,
|
||||
mapContactToDraft,
|
||||
mapRibToDraft,
|
||||
paymentTypeCodeOf,
|
||||
referentialOptionOf,
|
||||
relationOf,
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
type ClientDetail,
|
||||
type SelectOption,
|
||||
} from '~/modules/commercial/utils/clientConsultation'
|
||||
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
|
||||
|
||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
|
||||
const { t } = useI18n()
|
||||
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.
|
||||
if (!can('commercial.clients.view')) {
|
||||
await navigateTo('/clients')
|
||||
}
|
||||
|
||||
const clientId = route.params.id as string
|
||||
|
||||
const { client, loading, error, load, archive, restore } = useClient(clientId)
|
||||
|
||||
// ── Permissions / visibilite des actions ───────────────────────────────────
|
||||
const canAccountingView = computed(() => can('commercial.clients.accounting.view'))
|
||||
const canEdit = computed(() => canEditClient(canAny))
|
||||
const isArchived = computed(() => client.value?.isArchived === true)
|
||||
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
|
||||
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
|
||||
|
||||
const headerTitle = computed(() => client.value?.companyName ?? t('commercial.clients.consultation.title'))
|
||||
|
||||
// ── Donnees derivees du payload (lecture seule) ────────────────────────────
|
||||
const relation = computed(() => (client.value ? relationOf(client.value) : { type: null, name: null }))
|
||||
const categoryIris = computed(() => (client.value?.categories ?? []).map(c => c['@id']))
|
||||
|
||||
const information = computed(() => ({
|
||||
description: client.value?.description ?? null,
|
||||
competitors: client.value?.competitors ?? null,
|
||||
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime renvoye.
|
||||
foundedAt: client.value?.foundedAt ? client.value.foundedAt.slice(0, 10) : null,
|
||||
employeesCount: client.value?.employeesCount != null ? String(client.value.employeesCount) : null,
|
||||
revenueAmount: client.value?.revenueAmount ?? null,
|
||||
profitAmount: client.value?.profitAmount ?? null,
|
||||
directorName: client.value?.directorName ?? null,
|
||||
}))
|
||||
|
||||
// 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(() => {
|
||||
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)))
|
||||
|
||||
// ── Options des selects (construites depuis l'EMBED, jamais via un GET de
|
||||
// referentiel : /categories et /sites sont en 403 pour les roles metier
|
||||
// non-admin, ce qui laisserait les libelles vides). ───────────────────────
|
||||
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') },
|
||||
])
|
||||
|
||||
// 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))
|
||||
const paymentDelayOptions = computed(() => referentialOptionOf(client.value?.paymentDelay))
|
||||
const paymentTypeOptions = computed(() => referentialOptionOf(client.value?.paymentType))
|
||||
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 }))
|
||||
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
information: 'mdi:account-outline',
|
||||
contact: 'mdi:account-box-plus-outline',
|
||||
address: 'mdi:map-marker-outline',
|
||||
transport: 'mdi:truck-delivery-outline',
|
||||
accounting: 'mdi:bank-circle-outline',
|
||||
statistics: 'mdi:finance',
|
||||
reports: 'mdi:file-document-edit-outline',
|
||||
exchanges: 'mdi:account-group-outline',
|
||||
}
|
||||
|
||||
const tabs = computed(() => tabKeys.value.map(key => ({
|
||||
key,
|
||||
label: t(`commercial.clients.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
})))
|
||||
|
||||
// Onglet initial : repris de l'edition au retour (history.state), sinon Information.
|
||||
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||
function goBack(): void {
|
||||
router.push('/clients')
|
||||
}
|
||||
|
||||
/** Bascule en edition en conservant l'onglet courant (via history.state). */
|
||||
function goEdit(): void {
|
||||
router.push({ path: `/clients/${clientId}/edit`, state: { tab: activeTab.value } })
|
||||
}
|
||||
|
||||
// ── Archivage / Restauration ────────────────────────────────────────────────
|
||||
const confirmOpen = ref(false)
|
||||
const toggling = ref(false)
|
||||
|
||||
function askToggleArchive(): void {
|
||||
confirmOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirme l'archivage ou la restauration (PATCH isArchived seul). Gere le 409
|
||||
* de conflit d'homonyme actif a la restauration (RG-1.23) avec un message dedie.
|
||||
*/
|
||||
async function confirmToggleArchive(): Promise<void> {
|
||||
if (toggling.value) return
|
||||
toggling.value = true
|
||||
const restoring = isArchived.value
|
||||
try {
|
||||
if (restoring) {
|
||||
await restore()
|
||||
toast.success({ title: t('commercial.clients.toast.restoreSuccess') })
|
||||
}
|
||||
else {
|
||||
await archive()
|
||||
toast.success({ title: t('commercial.clients.toast.archiveSuccess') })
|
||||
}
|
||||
confirmOpen.value = false
|
||||
}
|
||||
catch (e) {
|
||||
const status = (e as { response?: { status?: number } })?.response?.status
|
||||
toast.error({
|
||||
title: t('commercial.clients.toast.error'),
|
||||
message: restoring && status === 409
|
||||
? t('commercial.clients.toast.restoreConflict')
|
||||
: t('commercial.clients.toast.error'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
toggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
useHead({ title: headerTitle })
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -1,434 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('commercial.clients.title') }}
|
||||
<template #actions>
|
||||
<!-- 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"
|
||||
:label="filterButtonLabel"
|
||||
icon-name="mdi:tune"
|
||||
icon-position="left"
|
||||
icon-size="24"
|
||||
@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>
|
||||
|
||||
<!-- Datatable branchee sur usePaginatedList via useClientsRepository :
|
||||
pagination serveur, tri companyName ASC par defaut (cote back). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
row-clickable
|
||||
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). -->
|
||||
<template #cell-categories="{ item }">
|
||||
{{ formatCategories(item) }}
|
||||
</template>
|
||||
|
||||
<!-- Sites : badges colores (name + color), agreges des adresses. -->
|
||||
<template #cell-sites="{ item }">
|
||||
<span class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="site in (item.sites as ClientSite[])"
|
||||
:key="site.id"
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 font-medium text-white"
|
||||
:style="{ backgroundColor: site.color }"
|
||||
>
|
||||
{{ site.name }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Derniere activite : date de derniere modification (updatedAt). -->
|
||||
<template #cell-lastActivity="{ item }">
|
||||
{{ formatLastActivity(item) }}
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<div class="flex justify-center mt-4">
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="primary"
|
||||
:label="t('commercial.clients.export')"
|
||||
:disabled="exporting"
|
||||
@click="exportXlsx"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
|
||||
« Appliquer ». Meme pattern que l'audit-log. 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('commercial.clients.filters.title') }}</h2>
|
||||
</template>
|
||||
|
||||
<MalioAccordion>
|
||||
<!-- Recherche : nom societe + contact + email (param `search`). -->
|
||||
<MalioAccordionItem :title="t('commercial.clients.filters.search')" value="search">
|
||||
<MalioInputText
|
||||
v-model="draftSearch"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Categories : cases a cocher (multi). Valeur = code stable. -->
|
||||
<MalioAccordionItem :title="t('commercial.clients.filters.categories')" value="categories">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in categoryOptions"
|
||||
:id="`filter-category-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftCategoryCodes.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleCategory(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Sites : cases a cocher (multi). Valeur = id du site. -->
|
||||
<MalioAccordionItem :title="t('commercial.clients.filters.sites')" value="sites">
|
||||
<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>
|
||||
|
||||
<!-- Statut : bool unique. Coche = archives uniquement, sinon actifs. -->
|
||||
<MalioAccordionItem :title="t('commercial.clients.filters.status')" value="status">
|
||||
<MalioCheckbox
|
||||
id="filter-archived-only"
|
||||
:label="t('commercial.clients.filters.archivedOnly')"
|
||||
:model-value="draftArchivedOnly"
|
||||
@update:model-value="(val: boolean) => draftArchivedOnly = val"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('commercial.clients.filters.reset')"
|
||||
button-class="w-m-btn-action"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('commercial.clients.filters.apply')"
|
||||
button-class="w-[170px]"
|
||||
@click="applyFilters"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { Client, ClientSite } from '~/modules/commercial/composables/useClientsRepository'
|
||||
|
||||
interface FilterOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('commercial.clients.title') })
|
||||
|
||||
// Bouton « Ajouter » reserve a `manage` (POST /clients garde manage seul →
|
||||
// Compta / Usine ne creent pas). « Exporter » et « Filtres » suivent `view`.
|
||||
const canManage = computed(() => can('commercial.clients.manage'))
|
||||
const canView = computed(() => can('commercial.clients.view'))
|
||||
|
||||
const {
|
||||
items: clients,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadClients,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
setFilters,
|
||||
} = useClientsRepository()
|
||||
|
||||
// Mappe les clients en objets « plats » pour MalioDataTable (items typees
|
||||
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
|
||||
// implicite, contrairement a l'interface Client. Meme pattern que sites.vue.
|
||||
const rows = computed(() => clients.value.map(client => ({
|
||||
id: client.id,
|
||||
companyName: client.companyName,
|
||||
categories: client.categories,
|
||||
sites: client.sites,
|
||||
updatedAt: client.updatedAt,
|
||||
})))
|
||||
|
||||
const columns = [
|
||||
{ key: 'companyName', label: t('commercial.clients.column.companyName') },
|
||||
{ key: 'categories', label: t('commercial.clients.column.categories') },
|
||||
{ key: 'sites', label: t('commercial.clients.column.sites') },
|
||||
{ key: 'lastActivity', label: t('commercial.clients.column.lastActivity') },
|
||||
]
|
||||
|
||||
/** Codes des categories du client, separes par une virgule (ERP-78). */
|
||||
function formatCategories(item: Record<string, unknown>): string {
|
||||
const categories = (item.categories as Client['categories']) ?? []
|
||||
return categories.map(c => c.code).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Derniere activite : faute de suivi d'activite metier au M1, on affiche la
|
||||
* date de derniere modification de la fiche (updatedAt, expose en liste via
|
||||
* default:read). Format court francais jj/mm/aaaa.
|
||||
*/
|
||||
function formatLastActivity(item: Record<string, unknown>): string {
|
||||
const value = item.updatedAt as string | null | undefined
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Garde-fou date invalide : un updatedAt mal forme donnerait « Invalid Date ».
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('fr-FR')
|
||||
}
|
||||
|
||||
/** Clic sur une ligne → ecran Consultation (route a plat /clients/{id}). */
|
||||
function onRowClick(item: Record<string, unknown>): void {
|
||||
router.push(`/clients/${item.id}`)
|
||||
}
|
||||
|
||||
function goToCreate(): void {
|
||||
router.push('/clients/new')
|
||||
}
|
||||
|
||||
// ── Filtres (drawer) ────────────────────────────────────────────────────────
|
||||
// Deux niveaux d'etat (pattern audit-log) :
|
||||
// - APPLIED : pilote la liste/l'export + 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 draftCategoryCodes = ref<string[]>([])
|
||||
const draftSiteIds = ref<string[]>([])
|
||||
const draftArchivedOnly = ref(false)
|
||||
|
||||
const appliedSearch = ref('')
|
||||
const appliedCategoryCodes = ref<string[]>([])
|
||||
const appliedSiteIds = ref<string[]>([])
|
||||
const appliedArchivedOnly = ref(false)
|
||||
|
||||
// Options des selects multi, chargees une fois (referentiels courts).
|
||||
const categoryOptions = ref<FilterOption[]>([])
|
||||
const siteOptions = ref<FilterOption[]>([])
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
let count = 0
|
||||
if (appliedSearch.value.trim() !== '') count++
|
||||
if (appliedCategoryCodes.value.length > 0) count++
|
||||
if (appliedSiteIds.value.length > 0) count++
|
||||
if (appliedArchivedOnly.value) count++
|
||||
return count
|
||||
})
|
||||
|
||||
const filterButtonLabel = computed(() => {
|
||||
const base = t('commercial.clients.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
|
||||
draftCategoryCodes.value = [...appliedCategoryCodes.value]
|
||||
draftSiteIds.value = [...appliedSiteIds.value]
|
||||
draftArchivedOnly.value = appliedArchivedOnly.value
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function toggleCategory(code: string, selected: boolean): void {
|
||||
draftCategoryCodes.value = selected
|
||||
? [...draftCategoryCodes.value, code]
|
||||
: draftCategoryCodes.value.filter(c => c !== code)
|
||||
}
|
||||
|
||||
function toggleSite(id: string, 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. Cles
|
||||
* `categoryCode[]` / `siteId[]` pour que PHP les parse en tableaux (OR cote back).
|
||||
* Les filtres vides sont omis pour une query propre.
|
||||
*/
|
||||
function buildFilterPayload(): Record<string, string | string[] | boolean> {
|
||||
const payload: Record<string, string | string[] | boolean> = {}
|
||||
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
||||
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
|
||||
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
|
||||
if (appliedArchivedOnly.value) payload.archivedOnly = true
|
||||
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()
|
||||
appliedCategoryCodes.value = [...draftCategoryCodes.value]
|
||||
appliedSiteIds.value = [...draftSiteIds.value]
|
||||
appliedArchivedOnly.value = draftArchivedOnly.value
|
||||
|
||||
setFilters(buildFilterPayload(), { replace: true })
|
||||
filterDrawerOpen.value = false
|
||||
}
|
||||
|
||||
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
|
||||
// Le drawer reste ouvert pour montrer le formulaire vide.
|
||||
function resetFilters(): void {
|
||||
draftSearch.value = ''
|
||||
draftCategoryCodes.value = []
|
||||
draftSiteIds.value = []
|
||||
draftArchivedOnly.value = false
|
||||
|
||||
appliedSearch.value = ''
|
||||
appliedCategoryCodes.value = []
|
||||
appliedSiteIds.value = []
|
||||
appliedArchivedOnly.value = false
|
||||
|
||||
setFilters({}, { replace: true })
|
||||
}
|
||||
|
||||
/** Charge les referentiels du drawer (categories + sites) via ?pagination=false. */
|
||||
async function loadFilterOptions(): Promise<void> {
|
||||
const [cats, sites] = await Promise.all([
|
||||
api.get<{ member?: Array<{ code: string, name: string }> }>(
|
||||
'/categories',
|
||||
// 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 }> }>(
|
||||
'/sites',
|
||||
{ pagination: 'false' },
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
),
|
||||
])
|
||||
|
||||
categoryOptions.value = (cats.member ?? []).map(c => ({ value: c.code, label: c.name }))
|
||||
siteOptions.value = (sites.member ?? []).map(s => ({ value: String(s.id), label: s.name }))
|
||||
}
|
||||
|
||||
// ── Export XLSX ─────────────────────────────────────────────────────────────
|
||||
// Memes filtres que la vue. La colonne SIREN n'est dans le fichier que si
|
||||
// l'utilisateur a accounting.view (gere cote back).
|
||||
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 — a generaliser via
|
||||
// un ticket dedie si d'autres exports binaires arrivent.
|
||||
const blob = await api.get<Blob>('/clients/export.xlsx', buildFilterPayload(), {
|
||||
responseType: 'blob',
|
||||
toast: false,
|
||||
} as unknown as Parameters<typeof api.get>[2])
|
||||
|
||||
triggerDownload(blob, 'repertoire-clients.xlsx')
|
||||
}
|
||||
catch {
|
||||
toast.error({
|
||||
title: t('commercial.clients.toast.error'),
|
||||
message: t('commercial.clients.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(() => {
|
||||
loadClients()
|
||||
// Echec du chargement des referentiels non bloquant : la liste s'affiche,
|
||||
// l'utilisateur perd juste les options de filtre.
|
||||
loadFilterOptions().catch(() => {
|
||||
categoryOptions.value = []
|
||||
siteOptions.value = []
|
||||
})
|
||||
})
|
||||
</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