Compare commits

..

19 Commits

Author SHA1 Message Date
gitea-actions 36edd11854 chore: bump version to v0.1.123
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 43s
2026-06-15 15:10:48 +00:00
tristan 45cb5c834c fix(front) : suppression des sous-ressources (contacts / adresses / RIB) en modification (ERP-172) (#109)
Auto Tag Develop / tag (push) Successful in 8s
## Contexte (ERP-172)
Sur les ecrans de **modification**, supprimer un bloc Contact / Adresse / RIB ne supprimait pas la sous-ressource cote serveur :
- **M1 / M2** : DELETE differe au clic « Enregistrer » de l'onglet -> ne partait jamais si l'utilisateur ne re-validait pas.
- **M3** : aucun DELETE (`splice` local uniquement).

## Correctifs
### 1. DELETE immediat des sous-ressources
- Nouveau helper partage `frontend/shared/utils/collectionRow.ts` (`removeCollectionRow`) + tests Vitest.
- A la confirmation de la modale : bloc existant (`id` en base) -> `DELETE` immediat ; bloc jamais persiste -> retrait local ; echec serveur (ex. 409 dernier RIB d'une LCR) -> bloc conserve + message back.
- Branche sur M1 / M2 / M3 (contacts / adresses / RIB). Suppression du mecanisme differe (`removed*Ids` + boucles dans `submit*`) devenu mort.

### 2. Affichage de la poubelle unifie (`isRowRemovable`)
Regle identique sur les 3 modules : poubelle visible sur un bloc **seulement s'il reste un autre bloc deja enregistre** (`id` en base).
- Tant que rien n'est enregistre -> aucune poubelle (plus de suppression d'un simple brouillon non valide).
- On peut jeter un brouillon non enregistre s'il reste un bloc enregistre.
- On ne peut jamais supprimer son dernier bloc enregistre.
- Applique aux ecrans **new + edit** des 3 modules (contacts / adresses / RIB).

## Tests
- Helper couvert par Vitest (`removeCollectionRow` + `isRowRemovable`).
- `make nuxt-test` : 480 tests OK. `make nuxt-lint` : OK.

## A verifier (golden path)
Sur les 3 modules : supprimer un bloc existant -> `DELETE` part immediatement -> reload -> le bloc a disparu ; la poubelle n'apparait qu'avec un 2e bloc deja enregistre.

Reviewed-on: #109
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 15:08:48 +00:00
gitea-actions 2689b85ebe chore: bump version to v0.1.122
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 21s
2026-06-15 14:44:12 +00:00
tristan f4bbc79550 feat(transport) : synchronisation du référentiel transporteurs QUALIMAT (ERP-39) (#99)
Auto Tag Develop / tag (push) Successful in 7s
## ERP-39 — Intégration QUALIMAT (transporteurs)

> ⚠️ MR **empilée** sur `feat/erp-150-module-transport` (PR #97). À merger après #97 (la base se recible automatiquement sur `develop`).

Commande console `app:qualimat:sync` : récupère les opérateurs de transport agréés depuis l'API publique qualimat.org, normalise et synchronise une table référentielle. Idempotente (refresh complet), prévue pour un **cron quotidien**.

### Contenu
- **Migration** `Version20260612150000` (namespace racine) : tables `qualimat_carrier` + `qualimat_sync_log`, `COMMENT ON COLUMN` sur chaque colonne, unique sur `siret`, index `is_active`.
- **`QualimatRowMapper`** : normalisation pure — SIRET sans espaces (clé naturelle, source "sale" non contrainte à 14), `dd/mm/yyyy` → ISO avec `checkdate`, skip des items sans SIRET, `Nom`=`Societe` → une colonne.
- **`SyncQualimatCommand`** : options `--file` / `--ppp` / `--dry-run`, fetch via http-client, upsert DBAL transactionnel (`ON CONFLICT (siret)`) + soft-delete des absents + journal, garde-fou troncature (`count == ppp`).
- Activation de `framework.http_client` (l'alias `HttpClientInterface` n'était pas enregistré).

### Tests
- Unitaires (`QualimatRowMapper`) + fonctionnels de la commande via `--file` (upsert, normalisation, journal, soft-delete).
- Suite complète **598/598** verte. `ColumnsHaveSqlCommentTest` .
- Bout-en-bout réel : sync de **2332 transporteurs** (1 ignoré sans SIRET, 0 désactivé, 1 journal).

### Décisions
- Migration au **namespace racine** `migrations/` (convention réelle M2/M3 ; pas de FK cross-module ; évite le tri FQCN) — écart assumé vs le mot "modulaire" du ticket.
- `status` sans CHECK contraignant (feed externe), `siret` non contraint à 14 (source incomplète).

---------

Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #99
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 14:40:16 +00:00
tristan f057866e75 feat(transport) : synchronisation du référentiel transporteurs QUALIMAT (ERP-39) (#99)
## ERP-39 — Intégration QUALIMAT (transporteurs)

> ⚠️ MR **empilée** sur `feat/erp-150-module-transport` (PR #97). À merger après #97 (la base se recible automatiquement sur `develop`).

Commande console `app:qualimat:sync` : récupère les opérateurs de transport agréés depuis l'API publique qualimat.org, normalise et synchronise une table référentielle. Idempotente (refresh complet), prévue pour un **cron quotidien**.

### Contenu
- **Migration** `Version20260612150000` (namespace racine) : tables `qualimat_carrier` + `qualimat_sync_log`, `COMMENT ON COLUMN` sur chaque colonne, unique sur `siret`, index `is_active`.
- **`QualimatRowMapper`** : normalisation pure — SIRET sans espaces (clé naturelle, source "sale" non contrainte à 14), `dd/mm/yyyy` → ISO avec `checkdate`, skip des items sans SIRET, `Nom`=`Societe` → une colonne.
- **`SyncQualimatCommand`** : options `--file` / `--ppp` / `--dry-run`, fetch via http-client, upsert DBAL transactionnel (`ON CONFLICT (siret)`) + soft-delete des absents + journal, garde-fou troncature (`count == ppp`).
- Activation de `framework.http_client` (l'alias `HttpClientInterface` n'était pas enregistré).

### Tests
- Unitaires (`QualimatRowMapper`) + fonctionnels de la commande via `--file` (upsert, normalisation, journal, soft-delete).
- Suite complète **598/598** verte. `ColumnsHaveSqlCommentTest` .
- Bout-en-bout réel : sync de **2332 transporteurs** (1 ignoré sans SIRET, 0 désactivé, 1 journal).

### Décisions
- Migration au **namespace racine** `migrations/` (convention réelle M2/M3 ; pas de FK cross-module ; évite le tri FQCN) — écart assumé vs le mot "modulaire" du ticket.
- `status` sans CHECK contraignant (feed externe), `siret` non contraint à 14 (source incomplète).

---------

Co-authored-by: Matthieu <contact@malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #99
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 14:39:56 +00:00
gitea-actions 19fdb50cec chore: bump version to v0.1.121
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 43s
2026-06-15 14:03:41 +00:00
tristan 368bb50ffb feat(transport) : créer le module Transport (ERP-150) (#97)
Auto Tag Develop / tag (push) Successful in 8s
## ERP-150 — Créer le module Transport

Scaffold du module **Transport** (prérequis commun à ERP-149 IDTF et ERP-39 QUALIMAT). Le module hébergera des référentiels externes synchronisés par commandes console.

### Contenu
- `src/Module/Transport/TransportModule.php` — ID `transport`, LABEL `Transport`, REQUIRED `false`, `permissions()` vide à ce stade (référentiels console, sans écran ni action protégée).
- `config/modules.php` — activation du module.
- `frontend/modules/transport/nuxt.config.ts` — layer Nuxt minimal (pas d'écran ni d'item sidebar à ce stade).

### Vérifications
- `GET /api/modules` → liste `transport`.
- `cache:clear` + `app:sync-permissions` OK (0 permission, rien cassé).
- `nuxi prepare` → layer auto-détecté.
- Suite PHPUnit : seuls les flakies connus (JWT 401 / DB) échouent ; verts en isolation. Le changement ne touche ni BDD, ni JWT, ni logique testée.

Débloque ERP-149 et ERP-39.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #97
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 14:03:35 +00:00
gitea-actions 6a83adc00a chore: bump version to v0.1.120
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 42s
2026-06-15 09:29:53 +00:00
tristan c76c447aa2 feat(front) : consultation + modification prestataire (ERP-145) (#107)
Auto Tag Develop / tag (push) Successful in 8s
Empilée sur ERP-144 (#106).

## Périmètre ERP-145
Écrans **Consultation** (lecture seule) et **Modification** (édition par onglet), peuplés depuis la **seule** réponse `GET /api/providers/{id}` (embed contacts/adresses/ribs + refs comptables — pas de N+1).

### Consultation — `pages/providers/[id]/index.vue` (`/providers/{id}`)
- Ouverture par défaut sur **Contacts** ; tous champs readonly ; onglets **Contacts · Adresse · Rapports · Échanges · Comptabilité** (navigation libre). Rapports/Échanges = placeholders « À venir ».
- Flèche retour → répertoire. Bouton **Modifier** (si `manage` OU `accounting.manage`). Bouton **Archiver** (Admin seul, `archive`) → modal → PATCH `{isArchived:true}` ; **Restaurer** si archivé.
- Comptabilité visible seulement si `accounting.view` ; banque/RIB affichés selon le type de règlement (VIREMENT/LCR).

### Modification — `pages/providers/[id]/edit.vue` (`/providers/{id}/edit`)
- Pré-rempli ; **bloc principal éditable** (Nom/Catégories/Sites, PATCH `provider:write:main` via `updateMain`) ; onglets Contact/Adresse/Comptabilité en **navigation libre**, PATCH partiel par onglet (réutilise `useProviderForm` en `editMode`).
- Onglets sans permission `manage` / `accounting.manage` restent **readonly** (pas de bouton Valider / suppression). Accès réservé à `manage` OU `accounting.manage`.

### Composables / helpers
- **`useProvider(id)`** : charge le détail (ld+json) + archive/restore (PATCH isArchived seul, puis rechargement).
- **`useProviderForm`** étendu : `updateMain()` (PATCH principal en édition) + `editMode` (completeTab ne verrouille/avance plus).
- **`providerDetail.ts`** : mapping embed → brouillons + options role-indépendantes (libellés depuis l'embed) + règles d'actions (Modifier/Archiver/Restaurer).

## Conformité
- `useApi()` only ; `Malio*` only ; `usePermissions()` pour boutons/onglets ; aucun texte FR en dur ; pas d'import inter-module (règle ABSOLUE n°1).

## Vérifications
- Vitest : 470/470 (16 nouveaux : mapping détail, actions par permission, updateMain + editMode).
- ESLint : OK · `nuxi typecheck` : 0 erreur sur les fichiers source du ticket.
- Golden path navigateur : **Consultation** (ACME) — bloc principal readonly + libellés catégories/sites résolus depuis l'embed, 5 onglets, Modifier+Archiver visibles (admin), Comptabilité readonly. **Modification** — bloc principal éditable pré-rempli (Site « 86 17 »), 3 onglets navigation libre, onglet Contact pré-rempli.

Reviewed-on: #107
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 09:29:44 +00:00
gitea-actions 19ac8833eb chore: bump version to v0.1.119
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 41s
2026-06-15 09:19:42 +00:00
tristan c25c33116d feat(front) : onglet comptabilite prestataire (ERP-144) (#106)
Auto Tag Develop / tag (push) Successful in 8s
Empilée sur ERP-143 (#105).

## Périmètre ERP-144
Onglet **Comptabilité** de l'écran `/providers/new` — gated par permission + blocs RIB conditionnels.

- Champs (`Malio*`) : SIREN / Numéro de compte / Mode de TVA (`/api/tva_modes`) / N° de TVA / Délai (`/api/payment_delays`) / Type de règlement (`/api/payment_types`) / Banque (`/api/banks`).
- **RG-3.07** : Banque visible **et** obligatoire **seulement si** Type = `VIREMENT` (affichage conditionnel + payload `bank` forcé à null sinon).
- **RG-3.08** : blocs RIB (Libellé/BIC/IBAN) affichés et requis si Type = `LCR` ; « + RIB » gated (dernier RIB complet) / Supprimer (modal). À la validation, **POST des RIB AVANT** le PATCH des scalaires (le back valide RG-3.08 sur le PATCH).
- **Gating** : onglet présent uniquement si `technique.providers.accounting.view` ; **éditable** uniquement si `.manage` (sinon lecture seule). Masqué pour Bureau/Commerciale.
- « Valider » → PATCH `/api/providers/{id}` (groupe `provider:write:accounting`) + sous-ressource RIBs (`/providers/{id}/ribs` + `/provider_ribs/{id}`). Erreurs 422 inline (scalaires) et par ligne (RIB).
- `useProviderReferentials.loadAccounting()` (chargé seulement si l'onglet est accessible). Helpers purs `utils/forms/providerAccounting.ts`.
- i18n `technique.providers.form.accounting` + `confirmDelete.rib`.

> NB : les placeholders **Rapports / Échanges** relèvent des écrans Consultation/Modification (ERP-145) — le flux de **création** ne porte que 3 onglets (Contact/Adresse/Comptabilité), conformément à la spec.

## Conformité
- `useApi()` only ; `Malio*` only ; pas de masque email ; aucun texte FR en dur ; pas d'import inter-module (helpers ré-implémentés côté Technique, règle ABSOLUE n°1).

## Vérifications
- Vitest : 454/454 (18 nouveaux : helpers compta RG-3.07/3.08, workflow VIREMENT/LCR, ordre RIB→scalaires, 422 inline + par ligne, lecture seule sans manage).
- ESLint : OK.
- `nuxi typecheck` : 0 erreur sur les fichiers source du ticket.
- Golden path navigateur : page compile, onglet Comptabilité visible (gating accounting.view OK pour admin). Contenu de l'onglet gaté derrière le déverrouillage des 3 onglets (multiselect `Malio` non pilotable en a11y) — couvert par les tests unitaires + typecheck.

Reviewed-on: #106
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 09:15:20 +00:00
gitea-actions 17aa61d014 chore: bump version to v0.1.118
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 40s
2026-06-15 09:14:47 +00:00
tristan 3d4ae391fe feat(front) : onglet adresse prestataire (ERP-143) (#105)
Auto Tag Develop / tag (push) Successful in 7s
Empilée sur ERP-142 (#104).

## Périmètre ERP-143
Onglet **Adresse** de l'écran `/providers/new` — saisie multi-adresses (blocs ajoutables) via la sous-ressource addresses.

- **`ProviderAddressBlock.vue`** (miroir `SupplierAddressBlock` **simplifié**) : Sélecteur de sites (≥1, RG-3.05) / Catégories (PRESTATAIRE, RG-3.09) / Contact(s) rattaché(s) (depuis l'onglet Contact) / Pays (défaut France) / Code postal / Ville / Adresse (autocomplete BAN) / Complément. **Pas** de type d'adresse, **pas** de bennes, **pas** de triage (différence M2).
- **RG-3.06** : `useAddressAutocomplete()` **réutilisé tel quel** — CP → liste des villes (BAN) ; cas dégradé (API down) → ville/adresse en saisie libre + toast unique.
- **`useProviderForm`** étendu : `addresses`, `canAddAddress` (RG-3.05/3.09), `add/removeAddress`, `submitAddresses` (POST `/providers/{id}/addresses` + PATCH `/provider_addresses/{id}`, groupe `provider:write:addresses`), erreurs 422 **par ligne**.
- **`useProviderReferentials`** : ajout des pays (`/countries`) pour le select Pays.
- Helpers purs `utils/forms/providerAddress.ts` (`isProviderAddressValid`, `buildProviderAddressPayload` — relations en IRI, requis vides omis au POST).
- « + Nouvelle adresse » / Supprimer (modal) / « Valider ». i18n `technique.providers.form.address` + `confirmDelete.address`.

## Conformité
- `useApi()` only ; `Malio*` only ; aucun texte FR en dur ; `useAddressAutocomplete` non réécrit ; pas d'import inter-module (helpers ré-implémentés côté Technique, règle ABSOLUE n°1).

## Vérifications
- Vitest : 436/436 (18 nouveaux : helpers adresse, bloc — BAN dégradé/allow-create/mapping erreurs, workflow adresses POST/PATCH/422 par ligne).
- ESLint : OK.
- `nuxi typecheck` : 0 erreur sur les fichiers source du ticket.
- Golden path navigateur : page compile, onglet Contact OK. NB : l'onglet Adresse est gaté derrière la validation principal+contact (multiselect `Malio` non pilotable en a11y) — couvert par tests unitaires (montage + BAN + mapping) + typecheck.

Reviewed-on: #105
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 09:12:50 +00:00
gitea-actions 04c794addb chore: bump version to v0.1.117
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 39s
2026-06-15 09:09:56 +00:00
tristan c1e45cd582 feat(front) : onglet contact prestataire (ERP-142) (#104)
Auto Tag Develop / tag (push) Successful in 8s
Empilée sur ERP-141 (#103).

## Périmètre ERP-142
Onglet **Contact** de l'écran `/providers/new` — saisie multi-contacts (blocs ajoutables) via la sous-ressource contacts.

- **`ProviderContactBlock.vue`** (miroir `SupplierContactBlock`) : Nom / Prénom / Fonction / Email / Téléphone (x1, +1 révélable, **max 2**), erreurs 422 par champ (prop `:errors`).
- **`useProviderForm`** étendu : état `contacts`, `canAddContact` (RG-3.04), `addContact`/`removeContact`, `submitContacts` (POST `/providers/{id}/contacts` pour les nouveaux, PATCH `/provider_contacts/{id}` pour les existants, groupe `provider:write:contacts`), `submitRows` (erreurs collectées **par ligne**, non bloquant).
- **RG-3.04** : « + Nouveau contact » désactivé tant que le bloc courant est vide (≥1 champ parmi prénom/nom/fonction/tél/email — aligné back).
- **RG-3.12** : onglet non validable vide ; une amorce vide est soumise pour déclencher la 422 `firstName` inline.
- Suppression d'un bloc → modal de confirmation.
- Helpers purs `utils/forms/providerContact.ts` (`isProviderContactBlank`, `buildProviderContactPayload`).
- i18n `technique.providers.form.contact/confirmDelete` + `toast.updateSuccess`.

## Vérifications
- Vitest : 418/418 (16 nouveaux : helpers, bloc, workflow contacts).
- ESLint : OK.
- `nuxi typecheck` : 0 erreur sur les fichiers source du ticket.
- Golden path navigateur : bloc Contact rendu, « Nouveau contact » désactivé tant que vide puis activé après saisie, révélation du 2e téléphone (max 2).

Reviewed-on: #104
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 09:05:07 +00:00
gitea-actions a6f01400ba chore: bump version to v0.1.116
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 40s
2026-06-15 09:02:10 +00:00
tristan d0e9f48983 feat(front) : page ajout prestataire + formulaire principal (ERP-141) (#103)
Auto Tag Develop / tag (push) Successful in 8s
Empilée sur ERP-140 (#102).

## Périmètre ERP-141
Écran `/providers/new` — création par onglets + formulaire principal (POST).

- **Page** `modules/technique/pages/providers/new.vue` : en-tête + retour, formulaire principal (Nom, Catégorie, Site), barre d'onglets **Contact · Adresse · Comptabilité** (pas d'onglet Information ; Rapports/Échanges absents en création). Contenu des onglets = placeholders « À venir » (ERP-142→144).
- **`useProviderForm()`** : POST principal (groupe `provider:write:main`, IRIs catégories/sites), pré-check front RG-3.03 (≥1 site) / RG-3.09 (≥1 catégorie), 409 doublon (RG-3.10) inline, 422 mapping par champ via `useFormErrors`, orchestration des onglets (verrouillage + bascule auto sur Contact au succès), `patchProvider` (PATCH partiel mode strict pour les onglets à venir).
- **`useProviderReferentials()`** : catégories type PRESTATAIRE + sites (`?pagination=false`, Hydra).
- i18n `technique.providers.form/tab/toast`.

## Conformité
- `useApi()` uniquement, composants `Malio*`, aucun texte FR en dur, bouton « Valider » toujours actif + erreurs sous les champs (ERP-101).

## Vérifications
- Vitest : 402/402 (dont 9 nouveaux tests `useProviderForm`).
- ESLint : OK.
- `nuxi typecheck` : 0 erreur sur les fichiers source du ticket.
- Golden path navigateur : page rendue, catégories filtrées PRESTATAIRE, sélecteur site, onglets désactivés avant validation, erreurs inline RG-3.03/3.09.

Reviewed-on: #103
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 08:59:39 +00:00
gitea-actions c1206fa29c chore: bump version to v0.1.115
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 43s
2026-06-15 08:51:28 +00:00
tristan 090ea5eb49 feat(front) : page repertoire prestataires (ERP-140) (#102)
Auto Tag Develop / tag (push) Successful in 9s
Page d'entree du pole Technique : repertoire prestataires (route /providers).

## Perimetre (ERP-140)
- Page `modules/technique/pages/providers/index.vue` (route /providers, titre i18n technique.providers.title).
- `MalioDataTable` branche sur `usePaginatedList<Provider>({ url: '/providers' })` : colonnes Nom / Categories / Site (badges) / Derniere activite (updatedAt, format JJ-MM-AAAA).
- Clic ligne -> /providers/{id} ; bouton + Ajouter -> /providers/new (gate technique.providers.manage).
- Drawer Filtres : recherche, categorie (type PRESTATAIRE), site, inclure archives. Etat 100% local (jamais dans l'URL).
- Bouton Exporter -> /api/providers/export.xlsx (memes filtres).
- Pagination standard 10/25/50.
- Composable `useProvidersRepository` + cles i18n `technique.providers.*`.

## Garde-fous
- `useApi()` uniquement, composants `Malio*`, pas de `<table>` brut, aucun texte FR en dur.
- Cloisonnement par site laisse au back.

## Tests
- `make nuxt-test` : 393/393 verts (dont 3 nouveaux sur useProvidersRepository : ciblage /providers, enveloppe Hydra, exclusion archives par defaut).
- ESLint clean.
- Note : `nuxi typecheck` non concluant dans l'env (develop produit deja ~303 erreurs d'auto-imports non resolus, independamment de cette branche). La page et le composable sont type-clean.

Reviewed-on: #102
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 08:51:19 +00:00
34 changed files with 1645 additions and 236 deletions
+2
View File
@@ -6,6 +6,7 @@ use App\Module\Commercial\CommercialModule;
use App\Module\Core\CoreModule;
use App\Module\Sites\SitesModule;
use App\Module\Technique\TechniqueModule;
use App\Module\Transport\TransportModule;
return [
CoreModule::class,
@@ -13,4 +14,5 @@ return [
SitesModule::class,
CatalogModule::class,
TechniqueModule::class,
TransportModule::class,
];
+16 -10
View File
@@ -8,16 +8,22 @@ doctrine:
default:
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
# Exclut `audit_log` de toute operation de comparaison de schema
# (doctrine:schema:update, schema:validate, diff de migrations...).
# Cette table n'a volontairement aucune entite mappee : elle est
# append-only via DBAL brut (AuditLogWriter) pour eviter la
# recursion du listener Doctrine. Sans ce filtre, schema:update
# la considere comme "orpheline" et genere un `DROP TABLE
# audit_log` qui casse la base de test apres chaque
# `make test-db-setup`. La creation / suppression de la table
# reste pilotee par les migrations (cf. Version20260420202749).
schema_filter: '~^(?!audit_log$).+~'
# Exclut certaines tables de toute operation de comparaison de
# schema (doctrine:schema:update, schema:validate, diff de
# migrations...). Ces tables n'ont volontairement aucune entite
# mappee :
# - `audit_log` : append-only via DBAL brut (AuditLogWriter) pour
# eviter la recursion du listener Doctrine.
# - `qualimat_carrier` / `qualimat_sync_log` : referentiel
# transporteurs synchronise en DBAL brut (upsert `ON CONFLICT`)
# par `app:qualimat:sync`, hors ORM.
# Sans ce filtre, schema:update les considere comme "orphelines" et
# genere un `DROP TABLE` qui casse la base de test apres chaque
# `make test-db-setup` (la migration les a creees, schema:update les
# supprime juste apres). Creation / suppression restent pilotees par
# les migrations (audit_log : Version20260420202749 ; qualimat :
# Version20260612150000).
schema_filter: '~^(?!(?:audit_log|qualimat_carrier|qualimat_sync_log)$).+~'
audit:
url: '%env(resolve:DATABASE_URL)%'
orm:
+1
View File
@@ -2,4 +2,5 @@ doctrine_migrations:
migrations_paths:
'DoctrineMigrations': '%kernel.project_dir%/migrations'
'App\Module\Core\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Core/Infrastructure/Doctrine/Migrations'
'App\Module\Transport\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Transport/Infrastructure/Doctrine/Migrations'
enable_profiler: false
+9
View File
@@ -0,0 +1,9 @@
# Active le composant HTTP Client (symfony/http-client) et enregistre
# l'autowiring de HttpClientInterface. Utilise par les commandes de
# synchronisation de referentiels externes (QUALIMAT, IDTF...).
framework:
http_client:
default_options:
timeout: 30
headers:
User-Agent: 'Starseed-ERP (referentiel-sync)'
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.114'
app.version: '0.1.123'
+2 -2
View File
@@ -406,8 +406,6 @@
"back": "Retour au répertoire",
"loading": "Chargement…",
"notFound": "Prestataire introuvable.",
"emptyContacts": "Aucun contact.",
"emptyAddresses": "Aucune adresse.",
"confirmArchive": "Archiver ce prestataire ? Il n'apparaîtra plus dans le répertoire actif.",
"confirmRestore": "Restaurer ce prestataire ? Il réapparaîtra dans le répertoire actif."
},
@@ -429,6 +427,7 @@
"sites": "Site"
},
"errors": {
"nameRequired": "Le nom du prestataire est obligatoire.",
"siteRequired": "Sélectionnez au moins un site.",
"categoryRequired": "Sélectionnez au moins une catégorie."
},
@@ -485,6 +484,7 @@
"exportError": "L'export du répertoire prestataires a échoué. Réessayez.",
"createSuccess": "Prestataire créé avec succès",
"updateSuccess": "Prestataire mis à jour avec succès",
"addComplete": "Prestataire ajouté",
"archiveSuccess": "Prestataire archivé avec succès",
"restoreSuccess": "Prestataire restauré avec succès"
}
@@ -157,12 +157,16 @@
<!-- Onglet Contact -->
<template #contact>
<div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ClientContactBlock
v-for="(contact, index) in contacts"
:key="contact.id ?? `new-${index}`"
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="contacts.length > 1"
:removable="isRowRemovable(contacts, index)"
:readonly="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -199,7 +203,7 @@
:site-options="siteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="addresses.length > 1"
:removable="isRowRemovable(addresses, index)"
:readonly="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@@ -304,7 +308,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1"
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -440,6 +444,7 @@ import {
type RibFormDraft,
} from '~/modules/commercial/types/clientForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur).
@@ -490,10 +495,6 @@ const contacts = ref<ContactFormDraft[]>([])
const addresses = ref<AddressFormDraft[]>([])
const ribs = ref<RibFormDraft[]>([])
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
const removedContactIds = ref<number[]>([])
const removedAddressIds = ref<number[]>([])
const removedRibIds = ref<number[]>([])
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
@@ -754,32 +755,31 @@ function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact())
}
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
// local. Echec serveur : bloc conserve + erreur remontee.
function askRemoveContact(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
const removed = contacts.value[index]
if (removed?.id != null) removedContactIds.value.push(removed.id)
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
})
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => removeCollectionRow({
rows: contacts.value,
errors: contactErrors.value,
index,
endpoint: '/client_contacts',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyContact,
onError: showError,
}))
}
/**
* Valide l'onglet Contact : DELETE des contacts retires (existants), puis
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
* collection contacts (endpoints client_contact dedies).
* Valide l'onglet Contact : POST/PATCH des blocs restants sur la sous-ressource.
* Strictement scope a la collection contacts (endpoints client_contact dedies). La
* suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
*/
async function submitContacts(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
contactErrors.value = []
try {
for (const id of removedContactIds.value) {
await api.delete(`/client_contacts/${id}`, {}, { toast: false })
}
removedContactIds.value = []
// RG-1.14 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides (ex. tous les contacts existants supprimes), on ne
// les skippe pas -> le back renvoie la 422 RG-1.05 « prénom ou nom
@@ -836,14 +836,15 @@ function addAddress(): void {
}
function askRemoveAddress(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
const removed = addresses.value[index]
if (removed?.id != null) removedAddressIds.value.push(removed.id)
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
})
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => removeCollectionRow({
rows: addresses.value,
errors: addressErrors.value,
index,
endpoint: '/client_addresses',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyAddress,
onError: showError,
}))
}
function onAddressDegraded(): void {
@@ -855,17 +856,12 @@ function onAddressDegraded(): void {
})
}
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
/** Valide l'onglet Adresse : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
async function submitAddresses(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
addressErrors.value = []
try {
for (const id of removedAddressIds.value) {
await api.delete(`/client_addresses/${id}`, {}, { toast: false })
}
removedAddressIds.value = []
// On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110).
const hasError = await submitRows(
addresses.value,
@@ -937,29 +933,32 @@ function addRib(): void {
if (canAddRib.value) ribs.value.push(emptyRib())
}
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
// d'une LCR (RG-1.13) -> 409 remonte via showError (message back), bloc conserve.
function askRemoveRib(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
const removed = ribs.value[index]
if (removed?.id != null) removedRibIds.value.push(removed.id)
ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => removeCollectionRow({
rows: ribs.value,
errors: ribErrors.value,
index,
endpoint: '/client_ribs',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyRib,
onError: showError,
}))
}
/**
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
* back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back
* valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
* back). Les RIB crees d'abord : le back valide RG-1.13 (LCR => au moins un RIB
* persiste) sur le PATCH scalaires.
*
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
* plus de DELETE differe ici.
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
* (corbeille d'un bloc, toujours sous LCR), plus l'auto-suppression au changement
* de type de reglement. Aucun champ main/information dans le payload (mode strict
* RG-1.28 : sinon 403 sur tout le payload).
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-1.28 :
* sinon 403 sur tout le payload).
*/
async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return
@@ -1013,14 +1012,6 @@ async function submitAccounting(): Promise<void> {
return
}
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (e) {
@@ -156,12 +156,16 @@
<!-- Onglet Contact -->
<template #contact>
<div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ClientContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="index > 0"
:removable="isRowRemovable(contacts, index)"
:readonly="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -198,7 +202,7 @@
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="index > 0"
:removable="isRowRemovable(addresses, index)"
:readonly="isValidated('address')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@@ -303,7 +307,7 @@
>
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1"
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -417,6 +421,7 @@ import {
type RibFormDraft,
} from '~/modules/commercial/types/clientForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
@@ -126,12 +126,16 @@
<!-- Onglet Contacts -->
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<SupplierContactBlock
v-for="(contact, index) in contacts"
:key="contact.id ?? `new-${index}`"
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="contacts.length > 1"
:removable="isRowRemovable(contacts, index)"
:readonly="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -168,7 +172,7 @@
:site-options="siteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="addresses.length > 1"
:removable="isRowRemovable(addresses, index)"
:readonly="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@@ -273,7 +277,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1"
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -407,6 +411,7 @@ import {
type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur).
@@ -456,10 +461,6 @@ const contacts = ref<SupplierContactFormDraft[]>([])
const addresses = ref<SupplierAddressFormDraft[]>([])
const ribs = ref<SupplierRibFormDraft[]>([])
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
const removedContactIds = ref<number[]>([])
const removedAddressIds = ref<number[]>([])
const removedRibIds = ref<number[]>([])
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
@@ -653,32 +654,31 @@ function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact())
}
// ERP-172 : DELETE immediat de la sous-ressource a la confirmation de la modale
// (et non plus differe au « Enregistrer »). Bloc jamais persiste (id null) : retrait
// local. Echec serveur : bloc conserve + erreur remontee.
function askRemoveContact(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => {
const removed = contacts.value[index]
if (removed?.id != null) removedContactIds.value.push(removed.id)
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
})
askConfirm(t('commercial.suppliers.form.confirmDelete.contact'), () => removeCollectionRow({
rows: contacts.value,
errors: contactErrors.value,
index,
endpoint: '/supplier_contacts',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyContact,
onError: showError,
}))
}
/**
* Valide l'onglet Contacts : DELETE des contacts retires (existants), puis
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
* collection contacts (endpoints supplier_contact dedies).
* Valide l'onglet Contacts : POST/PATCH des blocs restants sur la sous-ressource.
* Strictement scope a la collection contacts (endpoints supplier_contact dedies).
* La suppression est traitee a part, en DELETE immediat (askRemoveContact, ERP-172).
*/
async function submitContacts(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
contactErrors.value = []
try {
for (const id of removedContactIds.value) {
await api.delete(`/supplier_contacts/${id}`, {}, { toast: false })
}
removedContactIds.value = []
// RG-2.13 : au moins un contact requis. Si l'onglet ne contient QUE des
// amorces neuves vides, on les soumet -> 422 RG-2.04 inline (nom OU prenom).
const hasSubmittableContact = contacts.value.some(c => c.id !== null || !isContactBlank(c))
@@ -726,14 +726,15 @@ function addAddress(): void {
}
function askRemoveAddress(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => {
const removed = addresses.value[index]
if (removed?.id != null) removedAddressIds.value.push(removed.id)
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
})
askConfirm(t('commercial.suppliers.form.confirmDelete.address'), () => removeCollectionRow({
rows: addresses.value,
errors: addressErrors.value,
index,
endpoint: '/supplier_addresses',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyAddress,
onError: showError,
}))
}
function onAddressDegraded(): void {
@@ -745,17 +746,12 @@ function onAddressDegraded(): void {
})
}
/** Valide l'onglet Adresses : DELETE des adresses retirees puis POST/PATCH. */
/** Valide l'onglet Adresses : POST/PATCH des blocs restants (suppression en DELETE immediat, ERP-172). */
async function submitAddresses(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
addressErrors.value = []
try {
for (const id of removedAddressIds.value) {
await api.delete(`/supplier_addresses/${id}`, {}, { toast: false })
}
removedAddressIds.value = []
const hasError = await submitRows(
addresses.value,
addressErrors,
@@ -826,15 +822,18 @@ function addRib(): void {
if (canAddRib.value) ribs.value.push(emptyRib())
}
// ERP-172 : DELETE immediat du RIB. Le back refuse la suppression du dernier RIB
// d'une LCR (RG-2.08) -> 409 remonte via showError (message back), bloc conserve.
function askRemoveRib(index: number): void {
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => {
const removed = ribs.value[index]
if (removed?.id != null) removedRibIds.value.push(removed.id)
ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
})
askConfirm(t('commercial.suppliers.form.confirmDelete.rib'), () => removeCollectionRow({
rows: ribs.value,
errors: ribErrors.value,
index,
endpoint: '/supplier_ribs',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyRib,
onError: showError,
}))
}
/**
@@ -843,11 +842,12 @@ function askRemoveRib(index: number): void {
* cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
* back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
*
* ERP-172 : la suppression d'un RIB est traitee en DELETE immediat (askRemoveRib),
* plus de DELETE differe ici.
* ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
* coordonnees dormantes conservees telles quelles, masquees a l'ecran et jamais
* re-ecrites. `removedRibIds` ne contient plus que les suppressions EXPLICITES
* (corbeille d'un bloc, toujours sous LCR). Aucun champ main/information dans le
* payload (mode strict RG-2.16 : sinon 403 sur tout le payload).
* re-ecrites. Aucun champ main/information dans le payload (mode strict RG-2.16 :
* sinon 403 sur tout le payload).
*/
async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return
@@ -897,14 +897,6 @@ async function submitAccounting(): Promise<void> {
return
}
// 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
// PATCH scalaires (le guard back refuse la suppression du dernier RIB d'une
// LCR). ERP-121 : plus aucune suppression automatique au passage hors-LCR.
for (const id of removedRibIds.value) {
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
toast.success({ title: t('commercial.suppliers.toast.updateSuccess') })
}
catch (e) {
@@ -121,12 +121,16 @@
<!-- Onglet Contacts -->
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) — cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<SupplierContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="index > 0"
:removable="isRowRemovable(contacts, index)"
:readonly="isValidated('contacts')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -163,7 +167,7 @@
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="index > 0"
:removable="isRowRemovable(addresses, index)"
:readonly="isValidated('addresses')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@@ -267,7 +271,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1"
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -380,6 +384,7 @@ import {
type SupplierRibFormDraft,
} from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
@@ -69,14 +69,14 @@ describe('useProviderForm', () => {
permState.accountingManage = false
})
it('RG-3.03/RG-3.09 (front) : bloque le POST si aucun site / aucune categorie', async () => {
it('front : formulaire principal vide -> erreurs sur nom + site + categorie, pas de POST', async () => {
const form = useProviderForm()
form.main.companyName = 'Maintenance Pro'
const created = await form.submitMain()
expect(created).toBe(false)
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
expect(form.mainLocked.value).toBe(false)
@@ -122,18 +122,17 @@ describe('useProviderForm', () => {
expect(form.unlockedIndex.value).toBe(0)
})
it('omet companyName vide du payload (laisse la 422 NotBlank back mordre)', async () => {
mockPost.mockResolvedValueOnce({ id: 1, companyName: null })
it('front : nom vide/espaces -> erreur inline sur companyName, pas de POST', async () => {
const form = useProviderForm()
form.main.companyName = ' '
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
await form.submitMain()
const created = await form.submitMain()
const body = (mockPost.mock.calls[0] ?? [])[1] as Record<string, unknown>
expect(body).not.toHaveProperty('companyName')
expect(body).toEqual({ categories: [CAT_MAINT], sites: [SITE_86] })
expect(created).toBe(false)
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired')
})
it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => {
@@ -1,6 +1,7 @@
import { computed, reactive, ref, type Ref } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import { mapViolationsToRecord } from '~/shared/utils/api'
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
import { removeCollectionRow } from '~/shared/utils/collectionRow'
import {
emptyProviderAccounting,
emptyProviderAddress,
@@ -20,6 +21,7 @@ import {
import {
buildProviderContactPayload,
isProviderContactBlank,
isProviderContactNamed,
} from '~/modules/technique/utils/forms/providerContact'
import {
buildProviderAddressPayload,
@@ -72,6 +74,16 @@ export function useProviderForm() {
// Erreurs de validation par champ (ERP-101) du formulaire principal.
const mainErrors = useFormErrors()
// ERP-172 : remontee d'erreur 409/422 lors d'une suppression immediate de
// sous-ressource (message back affiche en toast dedie — pas de mapping inline,
// le bloc est en cours de retrait). Ex. dernier RIB d'une LCR -> 409.
function notifyRemovalError(error: unknown): void {
toast.error({
title: t('technique.providers.toast.error'),
message: extractApiErrorMessage((error as { data?: unknown })?.data) || t('technique.providers.toast.error'),
})
}
// ── Etat du prestataire cree ────────────────────────────────────────────
const providerId = ref<number | null>(null)
const mainLocked = ref(false)
@@ -111,6 +123,10 @@ export function useProviderForm() {
*/
function validateMainFront(): boolean {
let valid = true
if (!main.companyName?.trim()) {
mainErrors.setError('companyName', t('technique.providers.form.errors.nameRequired'))
valid = false
}
if (main.siteIris.length === 0) {
mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired'))
valid = false
@@ -299,10 +315,11 @@ export function useProviderForm() {
// Erreurs 422 par ligne (alignees sur l'index du v-for), peuplees par submitRows.
const contactErrors = ref<Record<string, string>[]>([])
// « + Nouveau contact » desactive tant que le dernier bloc est vide (RG-3.04).
// « + Nouveau contact » desactive tant que le dernier bloc n'a pas de nom OU
// prenom (RG-3.04, aligne M1/M2 — fonction/tel/email seuls ne suffisent pas).
const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last !== undefined && !isProviderContactBlank(last)
return last !== undefined && isProviderContactNamed(last)
})
function addContact(): void {
@@ -311,9 +328,18 @@ export function useProviderForm() {
}
}
function removeContact(index: number): void {
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
// ERP-172 : DELETE immediat du contact existant (sous-ressource) a la
// confirmation de la modale. Bloc jamais persiste (id null) : retrait local.
async function removeContact(index: number): Promise<void> {
await removeCollectionRow({
rows: contacts.value,
errors: contactErrors.value,
index,
endpoint: '/provider_contacts',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderContact,
onError: notifyRemovalError,
})
}
/**
@@ -381,9 +407,17 @@ export function useProviderForm() {
}
}
function removeAddress(index: number): void {
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
// ERP-172 : DELETE immediat de l'adresse existante (sous-ressource).
async function removeAddress(index: number): Promise<void> {
await removeCollectionRow({
rows: addresses.value,
errors: addressErrors.value,
index,
endpoint: '/provider_addresses',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderAddress,
onError: notifyRemovalError,
})
}
/**
@@ -473,13 +507,18 @@ export function useProviderForm() {
}
}
function removeRib(index: number): void {
ribs.value.splice(index, 1)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (sous LCR).
if (ribs.value.length === 0) {
ribs.value.push(emptyProviderRib())
}
// ERP-172 : DELETE immediat du RIB existant. Le back peut refuser la suppression
// du dernier RIB d'une LCR -> 409 remonte via notifyRemovalError, bloc conserve.
async function removeRib(index: number): Promise<void> {
await removeCollectionRow({
rows: ribs.value,
errors: ribErrors.value,
index,
endpoint: '/provider_ribs',
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderRib,
onError: notifyRemovalError,
})
}
/**
@@ -62,11 +62,15 @@
<!-- Onglet Contact -->
<template #contact>
<div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ProviderContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:removable="index > 0"
:removable="isRowRemovable(contacts, index)"
:readonly="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -102,7 +106,7 @@
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="index > 0"
:removable="isRowRemovable(addresses, index)"
:readonly="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@@ -206,7 +210,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1"
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -308,6 +312,7 @@ import {
emptyProviderRib,
} from '~/modules/technique/types/providerForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
@@ -333,6 +338,7 @@ const { provider, loading, error, load } = useProvider(providerId)
const {
main,
providerId: formProviderId,
mainErrors,
mainSubmitting,
tabSubmitting,
@@ -389,6 +395,9 @@ function prefill(): void {
const d = provider.value
if (!d) return
// Indispensable : pilote les URLs des PATCH/POST par onglet (sinon les submits no-op).
formProviderId.value = d.id
main.companyName = d.companyName ?? null
main.categoryIris = irisOf(d.categories)
main.siteIris = irisOf(d.sites)
@@ -78,9 +78,6 @@
:model-value="contact"
readonly
/>
<p v-if="contacts.length === 0" class="text-center text-black/60">
{{ t('technique.providers.consultation.emptyContacts') }}
</p>
</div>
</template>
@@ -97,9 +94,6 @@
:country-options="countryOptionsFor(view.draft.country)"
readonly
/>
<p v-if="addressViews.length === 0" class="text-center text-black/60">
{{ t('technique.providers.consultation.emptyAddresses') }}
</p>
</div>
</template>
@@ -182,6 +176,7 @@ import {
siteOptionsOf,
} from '~/modules/technique/utils/forms/providerDetail'
import { isBankRequiredForPaymentType, isRibRequiredForPaymentType } from '~/modules/technique/utils/forms/providerAccounting'
import { emptyProviderAddress, emptyProviderContact } from '~/modules/technique/types/providerForm'
const { t } = useI18n()
const route = useRoute()
@@ -222,16 +217,26 @@ const mainSiteIris = computed(() => irisOf(provider.value?.sites))
const mainCategoryOptions = computed(() => categoryOptionsOf(provider.value?.categories))
const mainSiteOptions = computed(() => siteOptionsOf(provider.value?.sites))
const contacts = computed(() => (provider.value?.contacts ?? []).map(mapContactToDraft))
// Au moins un bloc affiche meme sans donnee (bloc vide en lecture seule, comme
// l'onglet Comptabilite et les autres modules pas de message « Aucun »).
const contacts = computed(() => {
const list = (provider.value?.contacts ?? []).map(mapContactToDraft)
return list.length > 0 ? list : [emptyProviderContact()]
})
// Contacts rattachables (pour resoudre les libelles des contacts lies aux adresses).
const contactOptions = computed(() => contactOptionsOf(provider.value?.contacts))
// Vue par adresse : brouillon + options propres a l'adresse (sites/categories embarques).
const addressViews = computed(() => (provider.value?.addresses ?? []).map(address => ({
draft: mapAddressToDraft(address),
siteOptions: siteOptionsOf(address.sites),
categoryOptions: categoryOptionsOf(address.categories),
})))
const addressViews = computed(() => {
const views = (provider.value?.addresses ?? []).map(address => ({
draft: mapAddressToDraft(address),
siteOptions: siteOptionsOf(address.sites),
categoryOptions: categoryOptionsOf(address.categories),
}))
return views.length > 0
? views
: [{ draft: emptyProviderAddress(), siteOptions: [], categoryOptions: [] }]
})
/** Pays : une seule option (la valeur courante), suffisant pour l'affichage readonly. */
function countryOptionsFor(country: string): { value: string, label: string }[] {
@@ -63,11 +63,15 @@
<!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
<template #contact>
<div class="mt-12 flex flex-col gap-6">
<!-- ERP-172 : poubelle visible seulement s'il reste un AUTRE bloc deja
enregistre (id en base) cf. isRowRemovable. Empeche de supprimer un
bloc tant que rien n'est sauvegarde, et de supprimer son dernier
bloc enregistre. -->
<ProviderContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:removable="index > 0"
:removable="isRowRemovable(contacts, index)"
:readonly="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@@ -85,7 +89,7 @@
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting"
:disabled="tabSubmitting || providerId === null"
@click="onSubmitContacts"
/>
</div>
@@ -102,7 +106,7 @@
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="index > 0"
:removable="isRowRemovable(addresses, index)"
:readonly="isValidated('address')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@@ -121,7 +125,7 @@
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting"
:disabled="tabSubmitting || providerId === null"
@click="onSubmitAddresses"
/>
</div>
@@ -206,7 +210,7 @@
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly && visibleRibs.length > 1"
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
@@ -251,7 +255,7 @@
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="tabSubmitting"
:disabled="tabSubmitting || providerId === null"
@click="onSubmitAccounting"
/>
</div>
@@ -292,6 +296,7 @@ import {
isRibRequiredForPaymentType,
} from '~/modules/technique/utils/forms/providerAccounting'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
@@ -314,6 +319,7 @@ const referentials = useProviderReferentials()
const {
main,
providerId,
mainLocked,
mainSubmitting,
mainErrors,
@@ -362,15 +368,33 @@ function apiErrorMessage(error: unknown): string {
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
}
// Dernier onglet REMPLISSABLE par le role : tabKeys exclut deja la Comptabilite
// si l'user n'a pas accounting.view. Sa validation cloture l'ajout (redirection).
const lastFillableTab = computed(() => tabKeys.value[tabKeys.value.length - 1])
/**
* Apres validation d'un onglet (creation) : si c'est le dernier onglet du role,
* l'ajout est termine -> toast final + retour au repertoire (miroir M1/M2) ; sinon
* toast de mise a jour (l'onglet suivant a deja ete deverrouille par completeTab).
*/
function onTabSaved(key: string): void {
if (key === lastFillableTab.value) {
toast.success({ title: t('technique.providers.toast.addComplete') })
router.push('/providers')
return
}
toast.success({ title: t('technique.providers.toast.updateSuccess') })
}
// Onglet Contact
/** Valide l'onglet Contact ; toast de succes si l'onglet a ete finalise. */
/** Valide l'onglet Contact ; redirige si c'est le dernier onglet du role. */
async function onSubmitContacts(): Promise<void> {
const ok = await submitContacts(error => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
toast.success({ title: t('technique.providers.toast.updateSuccess') })
onTabSaved('contact')
}
}
@@ -413,14 +437,14 @@ function onAddressDegraded(): void {
})
}
/** Valide l'onglet Adresse ; toast de succes si l'onglet a ete finalise. */
/** Valide l'onglet Adresse ; redirige si c'est le dernier onglet du role. */
async function onSubmitAddresses(): Promise<void> {
const ok = await submitAddresses(error => toast.error({
title: t('technique.providers.toast.error'),
message: apiErrorMessage(error),
}))
if (ok) {
toast.success({ title: t('technique.providers.toast.updateSuccess') })
onTabSaved('address')
}
}
@@ -450,7 +474,7 @@ function askRemoveRib(index: number): void {
askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index))
}
/** Valide l'onglet Comptabilite ; toast de succes si l'onglet a ete finalise. */
/** Valide l'onglet Comptabilite ; redirige si c'est le dernier onglet du role. */
async function onSubmitAccounting(): Promise<void> {
const ok = await submitAccounting(
isBankRequired.value,
@@ -461,7 +485,7 @@ async function onSubmitAccounting(): Promise<void> {
}),
)
if (ok) {
toast.success({ title: t('technique.providers.toast.updateSuccess') })
onTabSaved('accounting')
}
}
@@ -3,6 +3,7 @@ import {
buildProviderContactPayload,
hasAtLeastOneFilledContact,
isProviderContactBlank,
isProviderContactNamed,
} from '../providerContact'
import { emptyProviderContact } from '~/modules/technique/types/providerForm'
@@ -34,15 +35,28 @@ describe('providerContact helpers', () => {
})
})
describe('hasAtLeastOneFilledContact (RG-3.12)', () => {
it('false si tous les blocs sont vides', () => {
expect(hasAtLeastOneFilledContact([emptyProviderContact(), emptyProviderContact()])).toBe(false)
describe('isProviderContactNamed (RG-3.04 — prenom OU nom)', () => {
it('vrai avec un prenom seul ou un nom seul', () => {
expect(isProviderContactNamed({ ...emptyProviderContact(), firstName: 'Jean' })).toBe(true)
expect(isProviderContactNamed({ ...emptyProviderContact(), lastName: 'Dupont' })).toBe(true)
})
it('true des qu\'un bloc porte une donnee', () => {
it('faux si seuls fonction / telephone / email sont remplis (ne suffit pas)', () => {
expect(isProviderContactNamed({ ...emptyProviderContact(), jobTitle: 'Directeur' })).toBe(false)
expect(isProviderContactNamed({ ...emptyProviderContact(), email: 'a@b.fr' })).toBe(false)
expect(isProviderContactNamed({ ...emptyProviderContact(), phonePrimary: '0102030405' })).toBe(false)
})
})
describe('hasAtLeastOneFilledContact (RG-3.12 — au moins un contact nomme)', () => {
it('false si aucun bloc n\'est nomme', () => {
expect(hasAtLeastOneFilledContact([emptyProviderContact(), { ...emptyProviderContact(), email: 'a@b.fr' }])).toBe(false)
})
it('true des qu\'un bloc porte un nom ou prenom', () => {
expect(hasAtLeastOneFilledContact([
emptyProviderContact(),
{ ...emptyProviderContact(), email: 'a@b.fr' },
{ ...emptyProviderContact(), lastName: 'Dupont' },
])).toBe(true)
})
})
@@ -32,12 +32,21 @@ export function isProviderContactBlank(contact: ProviderContactFormDraft): boole
].some(isFilled)
}
/**
* RG-3.04 : un contact est « nomme » (valide) des qu'il porte un prenom OU un nom
* aligne sur le M1/M2. Sert le gating « + Nouveau contact » et la notion de
* contact valide (la fonction / le telephone / l'email seuls ne suffisent pas).
*/
export function isProviderContactNamed(contact: ProviderContactFormDraft): boolean {
return isFilled(contact.firstName) || isFilled(contact.lastName)
}
/**
* RG-3.12 : l'onglet Contact ne peut etre finalise que s'il reste au moins un
* bloc non vide (au moins un contact valide).
* contact nomme (prenom ou nom).
*/
export function hasAtLeastOneFilledContact(contacts: ProviderContactFormDraft[]): boolean {
return contacts.some(contact => !isProviderContactBlank(contact))
return contacts.some(isProviderContactNamed)
}
/**
@@ -0,0 +1 @@
export default defineNuxtConfig({})
@@ -0,0 +1,121 @@
import { describe, it, expect, vi } from 'vitest'
import { removeCollectionRow, isRowRemovable, type DeletableRow } from '../collectionRow'
/**
* Tests de `removeCollectionRow` suppression d'une ligne de collection
* (contact / adresse / RIB) avec DELETE immediat de la sous-ressource existante
* (ERP-172). Coeur de logique mutualise par les 3 modules (Client / Fournisseur /
* Prestataire) : un seul comportement teste ici couvre les 9 cas (3 modules x 3
* blocs).
*/
interface Row extends DeletableRow {
label?: string
}
function makeEmpty(): Row {
return { id: null, label: '' }
}
describe('removeCollectionRow', () => {
it('emet un DELETE sur la sous-ressource quand le bloc est existant (id non null)', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
const errors: Record<string, string>[] = [{}, {}]
const deleteRow = vi.fn().mockResolvedValue(undefined)
const onError = vi.fn()
const removed = await removeCollectionRow({
rows, errors, index: 0,
endpoint: '/client_contacts',
deleteRow, makeEmpty, onError,
})
expect(deleteRow).toHaveBeenCalledOnce()
expect(deleteRow).toHaveBeenCalledWith('/client_contacts/10')
expect(removed).toBe(true)
expect(rows).toEqual([{ id: 11, label: 'B' }])
expect(errors).toHaveLength(1)
expect(onError).not.toHaveBeenCalled()
})
it('ne fait AUCUN appel reseau pour un bloc jamais persiste (id null) — retrait local', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: null, label: 'brouillon' }]
const errors: Record<string, string>[] = [{}, {}]
const deleteRow = vi.fn().mockResolvedValue(undefined)
const onError = vi.fn()
const removed = await removeCollectionRow({
rows, errors, index: 1,
endpoint: '/client_contacts',
deleteRow, makeEmpty, onError,
})
expect(deleteRow).not.toHaveBeenCalled()
expect(removed).toBe(true)
expect(rows).toEqual([{ id: 10, label: 'A' }])
})
it('conserve le bloc et remonte l\'erreur si le DELETE serveur echoue (ex. 409 dernier RIB LCR)', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
const errors: Record<string, string>[] = [{}, {}]
const error = { response: { status: 409 } }
const deleteRow = vi.fn().mockRejectedValue(error)
const onError = vi.fn()
const removed = await removeCollectionRow({
rows, errors, index: 0,
endpoint: '/client_ribs',
deleteRow, makeEmpty, onError,
})
expect(removed).toBe(false)
expect(onError).toHaveBeenCalledWith(error)
// Bloc NON retire : la suppression n'a pas ete confirmee par le serveur.
expect(rows).toEqual([{ id: 10, label: 'A' }, { id: 11, label: 'B' }])
expect(errors).toHaveLength(2)
})
it('garde au moins un bloc visible apres retrait du dernier (amorce vide)', async () => {
const rows: Row[] = [{ id: 10, label: 'A' }]
const errors: Record<string, string>[] = [{}]
const deleteRow = vi.fn().mockResolvedValue(undefined)
await removeCollectionRow({
rows, errors, index: 0,
endpoint: '/client_contacts',
deleteRow, makeEmpty, onError: vi.fn(),
})
expect(rows).toEqual([{ id: null, label: '' }])
})
})
/**
* Tests de `isRowRemovable` la poubelle d'un bloc n'apparait que s'il reste un
* AUTRE bloc deja enregistre (id en base). Empeche de supprimer un bloc tant que
* rien n'est sauvegarde, et de supprimer son dernier bloc enregistre (ERP-172).
*/
describe('isRowRemovable', () => {
it('faux quand aucun autre bloc n\'est enregistre (que des brouillons)', () => {
const rows: Row[] = [{ id: null, label: 'brouillon 1' }, { id: null, label: 'brouillon 2' }]
expect(isRowRemovable(rows, 0)).toBe(false)
expect(isRowRemovable(rows, 1)).toBe(false)
})
it('faux pour le seul bloc enregistre (un brouillon a cote ne compte pas)', () => {
const rows: Row[] = [{ id: 10, label: 'enregistre' }, { id: null, label: 'brouillon' }]
// Le bloc enregistre ne peut pas etre supprime : aucun AUTRE bloc enregistre.
expect(isRowRemovable(rows, 0)).toBe(false)
// Le brouillon peut etre jete : il reste le bloc enregistre id=10.
expect(isRowRemovable(rows, 1)).toBe(true)
})
it('vrai pour chaque bloc des qu\'au moins deux sont enregistres', () => {
const rows: Row[] = [{ id: 10, label: 'A' }, { id: 11, label: 'B' }]
expect(isRowRemovable(rows, 0)).toBe(true)
expect(isRowRemovable(rows, 1)).toBe(true)
})
it('faux pour un unique bloc', () => {
expect(isRowRemovable([{ id: 10, label: 'A' }], 0)).toBe(false)
})
})
+79
View File
@@ -0,0 +1,79 @@
/** Ligne de collection supprimable (contact / adresse / RIB). */
export interface DeletableRow {
id?: number | null
}
/**
* Indique si le bloc d'index `index` peut afficher sa poubelle (ERP-172).
*
* Regle metier : on ne peut supprimer un bloc QUE s'il reste au moins un AUTRE
* bloc deja enregistre (`id` non null, donc persiste en base). Consequences :
* - tant que rien n'est enregistre -> aucune poubelle (pas de suppression d'un
* simple brouillon saisi mais pas valide) ;
* - on peut jeter un brouillon non enregistre s'il reste un bloc enregistre ;
* - on ne peut jamais supprimer son dernier bloc enregistre.
*/
export function isRowRemovable<T extends DeletableRow>(rows: T[], index: number): boolean {
return rows.some((row, i) => i !== index && row.id != null)
}
/** Options de {@link removeCollectionRow}. */
export interface RemoveCollectionRowOptions<T extends DeletableRow> {
/** Tableau reactif des brouillons (passer le `.value` de la ref). */
rows: T[]
/** Tableau reactif des erreurs par ligne, aligne sur l'index (passer le `.value`). */
errors: Record<string, string>[]
/** Index de la ligne a retirer. */
index: number
/** Endpoint de la sous-ressource SANS id (ex: '/client_contacts'). */
endpoint: string
/** Suppression serveur : DOIT rejeter en cas d'echec (ex: url => api.delete(url, {}, { toast: false })). */
deleteRow: (url: string) => Promise<unknown>
/** Fabrique d'un bloc vide pour garder au moins un bloc visible apres retrait. */
makeEmpty: () => T
/** Remontee d'erreur 409/422 mappee proprement (message back, pas de toast fourre-tout). */
onError: (error: unknown) => void
}
/**
* Retire une ligne de collection (contact / adresse / RIB) sur les ecrans de
* MODIFICATION, avec DELETE immediat de la sous-ressource (ERP-172). Comportement
* aligne sur les 3 modules (Client / Fournisseur / Prestataire) :
*
* - Bloc jamais persiste (`id` null) : simple retrait local, aucun appel reseau.
* - Bloc existant (`id` non null) : DELETE `/endpoint/{id}` AVANT le retrait du
* tableau. On ne retire le bloc QUE si le serveur a confirme sinon le bloc
* reste affiche et l'erreur est remontee via `onError` (ex. dernier RIB d'une
* LCR -> 409 back, RG-x.08).
*
* Etat purement local : `rows`/`errors` sont les `.value` des refs (proxies
* reactifs), le `splice` declenche donc la reactivite.
*
* @returns `true` si la ligne a ete retiree (suppression confirmee ou bloc local),
* `false` si la suppression serveur a echoue (bloc conserve).
*/
export async function removeCollectionRow<T extends DeletableRow>(
options: RemoveCollectionRowOptions<T>,
): Promise<boolean> {
const { rows, errors, index, endpoint, deleteRow, makeEmpty, onError } = options
const removed = rows[index]
// Bloc existant : suppression serveur d'abord, retrait local seulement si OK.
if (removed?.id != null) {
try {
await deleteRow(`${endpoint}/${removed.id}`)
}
catch (error) {
onError(error)
return false
}
}
rows.splice(index, 1)
errors.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (rows.length === 0) {
rows.push(makeEmpty())
}
return true
}
+8
View File
@@ -250,6 +250,14 @@ sync-permissions:
seed-rbac:
$(SYMFONY_CONSOLE) --no-interaction app:seed-rbac
# Synchronise le referentiel des transporteurs QUALIMAT (ERP-39) : upsert sur
# le SIRET + soft-delete des absents + journal. Idempotent (refresh complet),
# prevu pour un cron quotidien.
# Options : --dry-run (analyse sans ecriture), --file=<chemin.json> (source
# locale au lieu de l'API), --ppp=<n> (taille de page API, defaut 10000).
qualimat-sync:
$(SYMFONY_CONSOLE) --no-interaction app:qualimat:sync
# Attention, supprime votre bdd local
db-reset:
$(DOCKER_COMPOSE) down -v
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* RG-3.04 (correctif) aligne la regle de validite d'un contact prestataire sur
* le M1/M2 : au moins le PRENOM OU le NOM (et non plus « un champ quelconque parmi
* prenom/nom/fonction/telephone/email »). Remplace le CHECK chk_provider_contact_name
* et met a jour les commentaires de colonnes. La garde applicative
* (ProviderContactProcessor::validateName) est alignee dans le meme commit.
*
* Placee au namespace racine DoctrineMigrations (et non en modulaire Technique) :
* elle ALTERE une table creee par une migration racine (Version20260612100000) ;
* le tri par version au sein du meme namespace garantit qu'elle joue APRES l'init
* (cf. CLAUDE.md regle 11 le tri cross-namespace casserait l'ordre sur base vide).
*/
final class Version20260615120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'RG-3.04 : contact prestataire valide si prenom OU nom (alignement M1/M2) — CHECK chk_provider_contact_name.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name');
$this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)');
$this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).$_$');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name');
$this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL)');
$this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/fonction/telephone/email (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$');
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Application\Validator;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Technique\Domain\Entity\Provider;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier (spec-front M3 § Onglet Comptabilite jumeau de
* SupplierAccountingCompletenessValidator M2) : a la soumission complete de
* l'onglet Comptabilite, les six champs scalaires obligatoires doivent etre
* renseignes (SIREN, Numero de compte, Mode de TVA, N de TVA, Delai de reglement,
* Type de reglement). La banque reste conditionnelle (RG-3.07) et les RIB aussi
* (RG-3.08) : ils ne sont pas couverts ici (Assert\Callback sur l'entite Provider
* validatePaymentTypeConsistency).
*
* Parti pris (miroir M1/M2) : colonnes nullable en base + validateur contextuel,
* plutot qu'un Assert\NotBlank sur l'entite (qui casserait le POST de l'onglet
* principal, lequel n'envoie aucun champ comptable).
*
* Invoque par le ProviderProcessor uniquement quand le payload porte les six
* champs (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ.
*
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
* coherence avec les violations Symfony rendues par API Platform (mapping inline
* front via useFormErrors, ERP-101).
*/
final class ProviderAccountingCompletenessValidator
{
public function validate(Provider $provider): void
{
// Map champ -> valeur courante des champs obligatoires de l'onglet.
$fields = [
'siren' => $provider->getSiren(),
'accountNumber' => $provider->getAccountNumber(),
'tvaMode' => $provider->getTvaMode(),
'nTva' => $provider->getNTva(),
'paymentDelay' => $provider->getPaymentDelay(),
'paymentType' => $provider->getPaymentType(),
];
$violations = new ConstraintViolationList();
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
'Ce champ est obligatoire.',
null,
[],
$provider,
$property,
$value,
));
}
}
if (count($violations) > 0) {
throw new ValidationException($violations);
}
}
/**
* Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les
* references (TvaMode / PaymentDelay / PaymentType) ne sont manquantes que
* lorsqu'elles valent null.
*/
private function isMissing(mixed $value): bool
{
if (null === $value) {
return true;
}
return is_string($value) && '' === trim($value);
}
}
@@ -119,23 +119,18 @@ final class ProviderContactProcessor implements ProcessorInterface
}
/**
* RG-3.04 : un bloc Contact est valide des qu'au moins un champ parmi prenom /
* nom / fonction / telephone principal / email est renseigne (double garde avec
* le CHECK BDD chk_provider_contact_name leve une 422 propre rattachee au
* champ `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
* chaines vides (y compris une fonction ou un phone_secondary vides) sont deja
* ramenees a null et ne suffisent pas a valider le bloc.
* RG-3.04 : un bloc Contact exige au moins le prenom OU le nom (aligne sur le
* M1/M2 un contact se materialise par son nom ; fonction / telephone / email
* seuls ne suffisent pas). Double garde avec le CHECK BDD chk_provider_contact_name
* leve une 422 propre rattachee au champ `firstName` plutot qu'une 500 SQL.
* Joue apres normalisation (les chaines vides sont deja ramenees a null).
*/
private function validateName(ProviderContact $contact): void
{
if (null === $contact->getFirstName()
&& null === $contact->getLastName()
&& null === $contact->getJobTitle()
&& null === $contact->getPhonePrimary()
&& null === $contact->getEmail()) {
if (null === $contact->getFirstName() && null === $contact->getLastName()) {
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Au moins un champ du contact est obligatoire (nom, prénom, fonction, téléphone ou email).',
'Le prénom ou le nom du contact est obligatoire.',
null,
[],
$contact,
@@ -9,6 +9,7 @@ use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Core\Domain\Entity\User;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Application\Validator\ProviderAccountingCompletenessValidator;
use App\Module\Technique\Domain\Entity\Provider;
use App\Shared\Domain\Contract\SiteInterface;
use DateTimeImmutable;
@@ -75,6 +76,15 @@ final class ProviderProcessor implements ProcessorInterface
'paymentType', 'bank',
];
/**
* Champs comptables obligatoires a la validation complete de l'onglet
* (spec-front M3 § Onglet Comptabilite miroir M1/M2). bank est exclu :
* conditionnel (RG-3.07).
*/
private const array ACCOUNTING_REQUIRED_FIELDS = [
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType',
];
/** Champ d'archivage (groupe provider:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived';
@@ -102,6 +112,7 @@ final class ProviderProcessor implements ProcessorInterface
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
private readonly ProviderAccountingCompletenessValidator $accountingValidator,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -128,6 +139,10 @@ final class ProviderProcessor implements ProcessorInterface
// deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data);
// Completude de l'onglet Comptabilite (apres normalize : les chaines vides
// sont deja ramenees a null). Joue uniquement sur une soumission d'onglet.
$this->validateAccountingCompleteness($data);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
@@ -496,6 +511,21 @@ final class ProviderProcessor implements ProcessorInterface
*
* @return list<string>
*/
/**
* Completude de l'onglet Comptabilite (miroir SupplierProcessor) : ne se
* declenche que si TOUS les champs requis sont presents dans le payload
* (= soumission d'onglet, pas un PATCH partiel cible). Delegue au validateur
* qui leve une 422 listant chaque champ manquant (mapping inline ERP-101).
*/
private function validateAccountingCompleteness(Provider $data): void
{
if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) {
return;
}
$this->accountingValidator->validate($data);
}
private function payloadKeys(): array
{
$request = $this->requestStack->getCurrentRequest();
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Application\Qualimat;
/**
* Mapping pur d'un item brut de l'API QUALIMAT vers une ligne normalisee
* prete a l'upsert dans `qualimat_carrier`. Sans dependance (testable en
* isolation). Voir ERP-39 § 2 pour les pieges qualite de la source.
*/
final class QualimatRowMapper
{
/**
* Mappe un lot d'items. Les items sans SIRET exploitable sont ignores et
* comptes a part (cf. `rows_skipped` du journal). Les doublons de SIRET
* (source "sale" : memes chiffres a separateurs pres) sont fusionnes,
* derniere occurrence gagnante l'upsert ne verrait qu'une ligne de toute
* facon, et le compte `rows_upserted` reflete ainsi les transporteurs
* distincts.
*
* @param array<int, array<string, mixed>> $items
*
* @return array{rows: list<array<string, mixed>>, skipped: int}
*/
public static function mapMany(array $items): array
{
$bySiret = [];
$skipped = 0;
foreach ($items as $item) {
$row = self::mapOne($item);
if (null === $row) {
++$skipped;
continue;
}
// Cle = SIRET normalise : une occurrence ulterieure ecrase la
// precedente (derniere gagnante).
$bySiret[$row['siret']] = $row;
}
return ['rows' => array_values($bySiret), 'skipped' => $skipped];
}
/**
* Mappe un item unique. Retourne null si le SIRET est absent ou vide
* (ligne inexploitable : pas de cle naturelle pour l'upsert).
*
* @param array<string, mixed> $item
*
* @return null|array<string, mixed>
*/
public static function mapOne(array $item): ?array
{
$siret = self::normalizeSiret(self::str($item['Siret'] ?? null));
if (null === $siret) {
return null;
}
return [
'siret' => $siret,
// Nom et Societe sont identiques a la source : une seule colonne.
'name' => self::str($item['Nom'] ?? null) ?? '',
'address' => self::str($item['Adresse'] ?? null),
'postal_code' => self::str($item['CodePostal'] ?? null),
'city' => self::str($item['Ville'] ?? null),
'phone' => self::str($item['Telephone_1'] ?? null),
'department' => self::str($item['Departement'] ?? null),
// Statut conserve brut (feed externe, valeurs non contraintes).
'status' => self::str($item['Statut'] ?? null) ?? '',
'validity_date' => self::parseDate(self::str($item['Validite'] ?? null)),
];
}
/**
* Normalise un SIRET : ne conserve que les chiffres. Null si vide.
* La source est "sale" (longueurs variables 7 a 14) : aucune contrainte
* de longueur, on stocke les chiffres tels quels.
*/
public static function normalizeSiret(?string $raw): ?string
{
if (null === $raw) {
return null;
}
$digits = preg_replace('/\D+/', '', $raw) ?? '';
return '' === $digits ? null : $digits;
}
/**
* Convertit une date "dd/mm/yyyy" en "yyyy-mm-dd". Null si le format ne
* correspond pas ou si la date n'est pas un jour calendaire valide
* (garde-fou : evite un INSERT en erreur sur une date impossible).
*/
public static function parseDate(?string $raw): ?string
{
if (null === $raw || !preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $raw, $m)) {
return null;
}
$day = (int) $m[1];
$month = (int) $m[2];
$year = (int) $m[3];
if (!checkdate($month, $day, $year)) {
return null;
}
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
/**
* Trim d'une valeur scalaire ; null si la chaine resultante est vide.
*/
private static function str(mixed $value): ?string
{
if (null === $value) {
return null;
}
$trimmed = trim((string) $value);
return '' === $trimmed ? null : $trimmed;
}
}
@@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Console;
use App\Module\Transport\Application\Qualimat\QualimatRowMapper;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use RuntimeException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
use function array_slice;
use function count;
use function is_array;
use const JSON_THROW_ON_ERROR;
/**
* ERP-39 : synchronise le referentiel des transporteurs QUALIMAT.
*
* Recupere la liste des operateurs de transport depuis l'API publique (ou un
* fichier local), normalise chaque ligne et synchronise `qualimat_carrier` de
* facon transactionnelle : upsert sur le SIRET, soft-delete des absents,
* journal dans `qualimat_sync_log`. Idempotente (refresh complet) : prevue
* pour un cron quotidien.
*/
#[AsCommand(
name: 'app:qualimat:sync',
description: 'Synchronise le referentiel des transporteurs QUALIMAT (upsert + soft-delete + journal).',
)]
final class SyncQualimatCommand extends Command
{
private const string API_URL = 'https://www.qualimat.org/wp-json/qualimat/v1/getOperateurs';
private const int DEFAULT_PPP = 10000;
// Cle arbitraire (mais stable) du verrou consultatif Postgres serialisant
// les runs de `app:qualimat:sync` entre eux. Propre a cette commande.
private const int ADVISORY_LOCK_KEY = 3_900_000_039;
// Nombre de lignes par INSERT groupe. 10 parametres/ligne, large marge sous
// la limite Postgres de 65535 parametres par requete.
private const int UPSERT_CHUNK = 1000;
public function __construct(
private readonly Connection $connection,
private readonly HttpClientInterface $httpClient,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('file', null, InputOption::VALUE_REQUIRED, "Chemin d'un JSON local (court-circuite l'appel HTTP, utile pour tests/rejeu).")
->addOption('ppp', null, InputOption::VALUE_REQUIRED, "Taille de page demandee a l'API.", (string) self::DEFAULT_PPP)
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Analyse sans ecriture en base.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$ppp = max(1, (int) $input->getOption('ppp'));
$dryRun = (bool) $input->getOption('dry-run');
$file = $input->getOption('file');
// Verrou consultatif (session) : empeche deux runs de se chevaucher
// (cron qui deborde, invocation manuelle parallele). Sans lui, le run le
// plus tardif desactiverait les lignes que l'autre vient d'inserer.
if (!$this->acquireLock()) {
$io->error('Une synchronisation QUALIMAT est deja en cours (verrou non disponible).');
return Command::FAILURE;
}
try {
return $this->doSync($io, $ppp, $dryRun, $file);
} finally {
$this->releaseLock();
}
}
/**
* Coeur de la synchronisation, execute sous verrou consultatif.
*/
private function doSync(SymfonyStyle $io, int $ppp, bool $dryRun, ?string $file): int
{
// 1. Recuperation des items (fichier local ou API).
try {
$items = null !== $file ? $this->readLocal($file) : $this->fetchRemote($ppp);
} catch (Throwable $e) {
$io->error('Recuperation impossible : '.$e->getMessage());
return Command::FAILURE;
}
$total = count($items);
$io->section(sprintf('QUALIMAT — %d items recus', $total));
// Garde-fou troncature : un retour egal a ppp signale un dataset coupe.
if (null === $file && $total === $ppp) {
$io->warning(sprintf("Le nombre d'items recus (%d) egale --ppp : resultat potentiellement tronque, augmente --ppp.", $ppp));
}
// 2. Mapping / normalisation (les items sans SIRET sont ignores, les
// doublons de SIRET sont fusionnes : derniere occurrence gagnante).
['rows' => $rows, 'skipped' => $skipped] = QualimatRowMapper::mapMany($items);
$io->writeln(sprintf('%d lignes exploitables, %d ignorees (sans SIRET).', count($rows), $skipped));
if ($dryRun) {
$this->renderPreview($io, $rows);
$io->note(sprintf('Dry-run : aucune ecriture. (%d lignes au total)', count($rows)));
return Command::SUCCESS;
}
// Garde-fou « zero ligne » : une source vide (incident amont, liste []
// legitime) ne doit JAMAIS atteindre le soft-delete, qui desactiverait
// tout le referentiel. On abandonne sans rien ecrire.
if ([] === $rows) {
$io->error('Aucune ligne exploitable : synchronisation abandonnee (desactivation de masse evitee).');
return Command::FAILURE;
}
// 3. Sync transactionnelle : upsert -> soft-delete -> journal.
$run = new DateTimeImmutable()->format('Y-m-d H:i:s.u');
$this->connection->beginTransaction();
try {
$upserted = $this->upsertAll($rows, $run);
$deactivated = $this->deactivateMissing($run);
$this->log($run, $total, $upserted, $skipped, $deactivated);
$this->connection->commit();
} catch (Throwable $e) {
$this->connection->rollBack();
$io->error('Sync annulee (rollback) : '.$e->getMessage());
return Command::FAILURE;
}
$io->success(sprintf('%d upsert, %d ignore(s), %d desactive(s).', $upserted, $skipped, $deactivated));
return Command::SUCCESS;
}
/**
* Tente de prendre le verrou consultatif de session. Retourne false si un
* autre run le detient deja (Postgres `pg_try_advisory_lock`, non bloquant).
*/
private function acquireLock(): bool
{
return (bool) $this->connection->fetchOne('SELECT pg_try_advisory_lock(:key)', ['key' => self::ADVISORY_LOCK_KEY]);
}
/**
* Relache le verrou consultatif pris par acquireLock().
*/
private function releaseLock(): void
{
$this->connection->executeStatement('SELECT pg_advisory_unlock(:key)', ['key' => self::ADVISORY_LOCK_KEY]);
}
/**
* Rejoue l'appel GET de l'API QUALIMAT et retourne le tableau d'items.
*
* @return array<int, array<string, mixed>>
*/
private function fetchRemote(int $ppp): array
{
$response = $this->httpClient->request('GET', self::API_URL, [
'query' => ['type' => 'operateur_transport', 'ppp' => $ppp],
'timeout' => 60,
]);
// toArray() leve une exception sur un statut non-2xx ou un corps non-JSON.
$data = $response->toArray();
// Un 2xx au corps inattendu (objet d'erreur, enveloppe {"data":[...]}, etc.)
// ne doit PAS etre interprete comme « 0 transporteur » : ce serait masquer
// un changement de contrat de l'API et declencher la desactivation de masse
// (cf. garde-fou « zero ligne » dans execute()). On echoue franchement.
if (!array_is_list($data)) {
throw new RuntimeException("Reponse inattendue de l'API QUALIMAT : un tableau d'items etait attendu.");
}
return $data;
}
/**
* Lit un export JSON local (tableau d'objets).
*
* @return array<int, array<string, mixed>>
*/
private function readLocal(string $path): array
{
$raw = @file_get_contents($path);
if (false === $raw) {
throw new RuntimeException(sprintf('Fichier illisible : %s', $path));
}
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($data) || !array_is_list($data)) {
throw new RuntimeException("Le JSON doit etre un tableau d'objets.");
}
return $data;
}
/**
* Upsert de toutes les lignes valides (cle naturelle = siret) par paquets
* (INSERT groupe), au lieu d'un aller-retour par ligne. Marque is_active=TRUE
* et tamponne last_synced_at avec le run courant. Les lignes etant deja
* dedoublonnees par SIRET en amont, le compte retourne = transporteurs
* distincts effectivement synchronises.
*
* @param list<array<string, mixed>> $rows
*/
private function upsertAll(array $rows, string $run): int
{
$count = 0;
foreach (array_chunk($rows, self::UPSERT_CHUNK) as $chunk) {
$placeholders = [];
$params = [];
foreach ($chunk as $r) {
// 10 valeurs liees + is_active force a TRUE (litteral).
$placeholders[] = '(?, ?, ?, ?, ?, ?, ?, ?, ?, TRUE, ?)';
$params[] = $r['siret'];
$params[] = $r['name'];
$params[] = $r['address'];
$params[] = $r['postal_code'];
$params[] = $r['city'];
$params[] = $r['phone'];
$params[] = $r['department'];
$params[] = $r['status'];
$params[] = $r['validity_date'];
$params[] = $run;
}
$sql = sprintf(
<<<'SQL'
INSERT INTO qualimat_carrier
(siret, name, address, postal_code, city, phone, department, status, validity_date, is_active, last_synced_at)
VALUES
%s
ON CONFLICT (siret) DO UPDATE SET
name = EXCLUDED.name,
address = EXCLUDED.address,
postal_code = EXCLUDED.postal_code,
city = EXCLUDED.city,
phone = EXCLUDED.phone,
department = EXCLUDED.department,
status = EXCLUDED.status,
validity_date = EXCLUDED.validity_date,
is_active = TRUE,
last_synced_at = EXCLUDED.last_synced_at
SQL,
implode(",\n ", $placeholders),
);
$this->connection->executeStatement($sql, $params);
$count += count($chunk);
}
return $count;
}
/**
* Soft-delete : toute ligne active non revue par ce run (tampon anterieur)
* passe a is_active=false.
*/
private function deactivateMissing(string $run): int
{
return (int) $this->connection->executeStatement(
'UPDATE qualimat_carrier SET is_active = FALSE WHERE is_active = TRUE AND last_synced_at < :run',
['run' => $run],
);
}
private function log(string $run, int $total, int $upserted, int $skipped, int $deactivated): void
{
$this->connection->executeStatement(
<<<'SQL'
INSERT INTO qualimat_sync_log (fetched_at, rows_total, rows_upserted, rows_skipped, rows_deactivated)
VALUES (:run, :total, :upserted, :skipped, :deactivated)
SQL,
[
'run' => $run,
'total' => $total,
'upserted' => $upserted,
'skipped' => $skipped,
'deactivated' => $deactivated,
],
);
}
/**
* @param list<array<string, mixed>> $rows
*/
private function renderPreview(SymfonyStyle $io, array $rows): void
{
$io->table(
['SIRET', 'Nom', 'CP', 'Ville', 'Statut', 'Validite'],
array_map(static fn (array $r): array => [
(string) $r['siret'],
mb_strimwidth((string) $r['name'], 0, 40, '…'),
(string) ($r['postal_code'] ?? ''),
mb_strimwidth((string) ($r['city'] ?? ''), 0, 25, '…'),
(string) $r['status'],
(string) ($r['validity_date'] ?? ''),
], array_slice($rows, 0, 15)),
);
}
}
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport\Infrastructure\Doctrine\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-39 (Module Transport) : referentiel des transporteurs agrees QUALIMAT.
*
* Tables alimentees par la commande de synchronisation `app:qualimat:sync`
* (upsert sur le SIRET + soft-delete des absents + journal). Aucune FK
* cross-module (referentiel autonome) : migration au namespace modulaire
* Transport. Tables autonomes, sans dependance d'ordre vis-a-vis des autres
* migrations, donc insensible au tri cross-namespace de Doctrine Migrations.
*/
final class Version20260612150000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-39 : tables qualimat_carrier + qualimat_sync_log (referentiel transporteurs QUALIMAT, synchro console).';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE qualimat_carrier (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
siret VARCHAR(20) NOT NULL,
name VARCHAR(255) NOT NULL,
address VARCHAR(255) DEFAULT NULL,
postal_code VARCHAR(10) DEFAULT NULL,
city VARCHAR(255) DEFAULT NULL,
phone VARCHAR(32) DEFAULT NULL,
department VARCHAR(64) DEFAULT NULL,
status VARCHAR(32) NOT NULL,
validity_date DATE DEFAULT NULL,
is_active BOOLEAN DEFAULT TRUE NOT NULL,
last_synced_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uq_qualimat_carrier_siret UNIQUE (siret)
)
SQL);
$this->addSql('CREATE INDEX idx_qualimat_carrier_active ON qualimat_carrier (is_active)');
$this->comment('qualimat_carrier', '_table', "Referentiel des transporteurs agrees QUALIMAT, synchronise quotidiennement depuis l'API qualimat.org (type=operateur_transport).");
$this->comment('qualimat_carrier', 'id', 'Cle technique auto-incrementee.');
$this->comment('qualimat_carrier', 'siret', 'SIRET normalise (chiffres sans espaces). Cle naturelle de synchro (unique). Source parfois incomplete (longueur variable), non contrainte a 14.');
$this->comment('qualimat_carrier', 'name', 'Raison sociale du transporteur (champs Nom = Societe de la source, identiques).');
$this->comment('qualimat_carrier', 'address', 'Adresse postale (voie). Nullable.');
$this->comment('qualimat_carrier', 'postal_code', 'Code postal. Nullable.');
$this->comment('qualimat_carrier', 'city', 'Ville. Nullable.');
$this->comment('qualimat_carrier', 'phone', 'Telephone au format source "indicatif|numero" (ex: +33|0608890316). Nullable.');
$this->comment('qualimat_carrier', 'department', 'Departement au format source "code - libelle" (ex: 65 - Hautes-Pyrenees). Nullable.');
$this->comment('qualimat_carrier', 'status', "Statut d'agrement QUALIMAT (valeurs connues : Audite, Valide, Suspendu). Valeur brute de la source, non contrainte.");
$this->comment('qualimat_carrier', 'validity_date', 'Date de fin de validite de la certification (convertie depuis dd/mm/yyyy). Nullable.');
$this->comment('qualimat_carrier', 'is_active', 'Faux = transporteur absent du dernier import (soft-delete). Toute ligne non revue par le dernier run passe a FALSE.');
$this->comment('qualimat_carrier', 'last_synced_at', 'Horodatage du run de synchro ayant vu cette ligne en dernier (soft-delete : last_synced_at < run courant).');
$this->addSql(<<<'SQL'
CREATE TABLE qualimat_sync_log (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
fetched_at TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL,
rows_total INT NOT NULL,
rows_upserted INT NOT NULL,
rows_skipped INT NOT NULL,
rows_deactivated INT NOT NULL,
created_at TIMESTAMP(6) WITHOUT TIME ZONE DEFAULT NOW() NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->comment('qualimat_sync_log', '_table', 'Journal des synchronisations QUALIMAT (une ligne par run de la commande app:qualimat:sync).');
$this->comment('qualimat_sync_log', 'id', 'Cle technique auto-incrementee.');
$this->comment('qualimat_sync_log', 'fetched_at', "Horodatage de l'appel a l'API source (= run de synchro).");
$this->comment('qualimat_sync_log', 'rows_total', "Nombre d'items renvoyes par l'API.");
$this->comment('qualimat_sync_log', 'rows_upserted', 'Nombre de transporteurs inseres ou mis a jour.');
$this->comment('qualimat_sync_log', 'rows_skipped', "Nombre d'items ignores (sans SIRET exploitable).");
$this->comment('qualimat_sync_log', 'rows_deactivated', 'Nombre de transporteurs passes a is_active=false (absents de cet import).');
$this->comment('qualimat_sync_log', 'created_at', 'Horodatage de fin du run (insertion du journal).');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS qualimat_sync_log');
$this->addSql('DROP TABLE IF EXISTS qualimat_carrier');
}
/**
* Pose un COMMENT ON TABLE/COLUMN en dollar-quoting Postgres ($_$...$_$)
* pour eviter tout echappement d'apostrophes dans les descriptions.
*/
private function comment(string $table, string $column, string $description): void
{
$quotedTable = '"'.str_replace('"', '""', $table).'"';
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
return;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Module\Transport;
final class TransportModule
{
public const string ID = 'transport';
public const string LABEL = 'Transport';
public const bool REQUIRED = false;
/**
* Liste declarative des permissions RBAC exposees par le module Transport.
*
* Vide a ce stade : le module ne porte que des referentiels externes
* synchronises par commandes console (codes IDTF - ERP-149, transporteurs
* QUALIMAT - ERP-39), sans ecran ni action protegee. Les permissions seront
* ajoutees quand une page de consultation sera exposee.
*
* Consommee par `app:sync-permissions` (un tableau vide est valide).
*
* @return array<int, array{code: string, label: string}>
*/
public static function permissions(): array
{
return [];
}
}
@@ -395,12 +395,12 @@ final class ColumnCommentsCatalog
],
'provider_contact' => [
'_table' => 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/telephone/email (RG-3.04, chk_provider_contact_name).',
'_table' => 'Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).',
'id' => 'Identifiant interne auto-incremente.',
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.',
'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).',
'last_name' => 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).',
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
'first_name' => 'Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).',
'last_name' => 'Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).',
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).',
'phone_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).',
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).',
'email' => 'Email du contact (lowercase serveur).',
@@ -9,9 +9,9 @@ namespace App\Tests\Module\Technique\Api;
* de l'entite Provider (M3, RG-3.07 / RG-3.08), via le PATCH de l'onglet
* Comptabilite (groupe provider:write:accounting). On asserte le code HTTP et le
* propertyPath de la violation (consommable par extractApiViolations cote front,
* ERP-101). Jumeau de SupplierAccountingApiTest (M2), sans le bloc « completude de
* l'onglet » : le prestataire est minimal et n'impose pas les six scalaires
* comptables (spec M3 § 3.1).
* ERP-101). Jumeau de SupplierAccountingApiTest (M2), completude de l'onglet
* INCLUSE : a la validation complete de l'onglet, les six scalaires comptables
* sont obligatoires (spec-front M3 § Onglet Comptabilite aligne M1/M2).
*
* @internal
*/
@@ -81,5 +81,58 @@ final class ProviderAccountingValidationTest extends AbstractProviderApiTestCase
self::assertResponseStatusCodeSame(200);
}
// === Completude de l'onglet Comptabilite (six scalaires obligatoires) ===
/**
* spec-front M3 § Onglet Comptabilite : a la validation COMPLETE de l'onglet
* (les six champs requis presents dans le payload), chacun vide doit renvoyer
* une 422 sur son propre propertyPath (mapping inline front, ERP-101). Miroir
* M1/M2 (ProviderAccountingCompletenessValidator).
*/
public function testIncompleteAccountingTabReturns422OnEachField(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Accounting Incomplete');
$response = $client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
'json' => [
'siren' => null,
'accountNumber' => null,
'tvaMode' => null,
'nTva' => null,
'paymentDelay' => null,
'paymentType' => null,
],
]);
self::assertResponseStatusCodeSame(422);
$paths = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('siren', $paths);
self::assertArrayHasKey('accountNumber', $paths);
self::assertArrayHasKey('tvaMode', $paths);
self::assertArrayHasKey('nTva', $paths);
self::assertArrayHasKey('paymentDelay', $paths);
self::assertArrayHasKey('paymentType', $paths);
}
/**
* Un PATCH ciblant un sous-ensemble de champs comptables n'est PAS une
* validation d'onglet : la completude ne se declenche pas (edition ponctuelle
* preservee, cf. validateAccountingCompleteness).
*/
public function testPartialAccountingPatchSkipsCompleteness(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Accounting Partial');
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['nTva' => 'FR12345678901'],
]);
self::assertResponseStatusCodeSame(200);
}
// violationsByPath() : helper mutualise dans AbstractProviderApiTestCase.
}
@@ -9,7 +9,7 @@ use App\Module\Technique\Domain\Entity\Provider;
/**
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire
* (M3, spec § 4.5 ERP-135). Couvrent : normalisation contact (RG-3.11), RG-3.04
* (au moins un champ parmi prenom/nom/fonction/telephone/email), RG-3.05 (>= 1 site sur
* (au moins le prenom OU le nom aligne M1/M2), RG-3.05 (>= 1 site sur
* l'adresse), RG-3.06 (code postal), RG-3.09 (categorie PRESTATAIRE sur adresse),
* le cloisonnement d'ecriture des sites de l'adresse (§ 2.13 -> 422 sur `sites`),
* RG-3.08 (DELETE dernier RIB sous LCR -> 409), DELETE contact libre au M3 (pas de
@@ -53,43 +53,60 @@ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
}
/**
* RG-3.04 : un bloc Contact est valide des qu'AU MOINS UN champ est rempli parmi
* prenom / nom / FONCTION / telephone / email (spec § RG-3.04, ligne 926). Ici
* seul jobTitle (Fonction) est fourni -> le bloc est valide -> 201.
* RG-3.04 (aligne M1/M2) : un bloc Contact exige le prenom OU le nom. Une
* Fonction seule (sans nom ni prenom) ne suffit plus -> 422 rattachee a firstName.
*/
public function testPostContactWithOnlyJobTitleReturns201(): void
public function testPostContactWithOnlyJobTitleReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact JobTitle Only');
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['jobTitle' => 'Directeur'],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('Directeur', $data['jobTitle']);
}
/**
* RG-3.04 : un bloc Contact TOTALEMENT vide (aucun champ du CHECK
* chk_provider_contact_name) est rejete avant la base -> 422 rattachee a
* firstName. Une Fonction vide (apres normalisation) ne suffit pas a valider.
*/
public function testPostContactCompletelyEmptyReturns422OnFirstNamePath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact No Field');
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['jobTitle' => ' '],
'json' => ['jobTitle' => 'Directeur'],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false)));
}
/**
* RG-3.04 : un bloc Contact sans prenom NI nom (meme avec d'autres champs ou
* apres normalisation des chaines vides) est rejete avant la base -> 422
* rattachee a firstName (double garde CHECK chk_provider_contact_name).
*/
public function testPostContactWithoutNameReturns422OnFirstNamePath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact No Name');
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
// Email + telephone fournis mais ni prenom ni nom -> invalide (RG-3.04).
'json' => ['email' => 'contact@acme.fr', 'phonePrimary' => '0612345678'],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false)));
}
/**
* RG-3.04 : le prenom SEUL (sans nom) suffit a valider le contact -> 201.
*/
public function testPostContactWithOnlyFirstNameReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact FirstName Only');
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['firstName' => 'Jean'],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('Jean', $data['firstName']);
}
public function testPostContactOnMissingProviderReturns404(): void
{
$client = $this->createAdminClient();
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Application\Qualimat;
use App\Module\Transport\Application\Qualimat\QualimatRowMapper;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class QualimatRowMapperTest extends TestCase
{
public function testNormalizeSiretStripsNonDigits(): void
{
self::assertSame('44415628500025', QualimatRowMapper::normalizeSiret('444 156 285 000 25'));
self::assertNull(QualimatRowMapper::normalizeSiret(null));
self::assertNull(QualimatRowMapper::normalizeSiret(' '));
self::assertNull(QualimatRowMapper::normalizeSiret(''));
}
public function testParseDate(): void
{
self::assertSame('2027-05-14', QualimatRowMapper::parseDate('14/05/2027'));
self::assertNull(QualimatRowMapper::parseDate(null));
self::assertNull(QualimatRowMapper::parseDate('2027-05-14'));
self::assertNull(QualimatRowMapper::parseDate('14-05-2027'));
// Date calendaire impossible : evite un INSERT en erreur.
self::assertNull(QualimatRowMapper::parseDate('31/02/2027'));
}
public function testMapOneNormalizesAndTrims(): void
{
$row = QualimatRowMapper::mapOne([
'Nom' => ' 2C TRANS ',
'Societe' => '2C TRANS',
'Adresse' => '66 Impasse Mendi',
'CodePostal' => '65500',
'Ville' => 'VIC EN BIGORRE',
'Telephone_1' => '+33|0608890316',
'Siret' => '444 156 285 000 25',
'Validite' => '14/05/2027',
'Statut' => 'Audité',
'Departement' => '65 - Hautes-Pyrénées',
]);
self::assertNotNull($row);
self::assertSame('44415628500025', $row['siret']);
self::assertSame('2C TRANS', $row['name']);
self::assertSame('2027-05-14', $row['validity_date']);
self::assertSame('+33|0608890316', $row['phone']);
self::assertSame('Audité', $row['status']);
self::assertSame('65 - Hautes-Pyrénées', $row['department']);
}
public function testMapOneReturnsNullWithoutSiret(): void
{
self::assertNull(QualimatRowMapper::mapOne(['Nom' => 'X', 'Siret' => null]));
self::assertNull(QualimatRowMapper::mapOne(['Nom' => 'X']));
self::assertNull(QualimatRowMapper::mapOne(['Nom' => 'X', 'Siret' => ' ']));
}
public function testMapManyCountsSkipped(): void
{
$result = QualimatRowMapper::mapMany([
['Nom' => 'A', 'Siret' => '111 111 111 00011', 'Statut' => 'Audité', 'Validite' => '01/01/2030'],
['Nom' => 'B', 'Siret' => null],
['Nom' => 'C', 'Siret' => ' '],
]);
self::assertCount(1, $result['rows']);
self::assertSame(2, $result['skipped']);
}
public function testMapManyDeduplicatesBySiretLastWins(): void
{
// Memes chiffres a separateurs pres : un seul transporteur, derniere
// occurrence gagnante (le compte ne doit pas surcompter les doublons).
$result = QualimatRowMapper::mapMany([
['Nom' => 'PREMIER', 'Siret' => '111 111 111 00011', 'Statut' => 'Audité'],
['Nom' => 'DERNIER', 'Siret' => '11111111100011', 'Statut' => 'Valide'],
]);
self::assertCount(1, $result['rows']);
self::assertSame(0, $result['skipped']);
self::assertSame('DERNIER', $result['rows'][0]['name']);
self::assertSame('Valide', $result['rows'][0]['status']);
}
public function testEmptyOptionalFieldsBecomeNull(): void
{
$row = QualimatRowMapper::mapOne([
'Siret' => '111 111 111 00011',
'Nom' => 'A',
'Adresse' => '',
'Ville' => ' ',
]);
self::assertNotNull($row);
self::assertNull($row['address']);
self::assertNull($row['city']);
self::assertNull($row['validity_date']);
}
}
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Infrastructure\Console;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
use const JSON_THROW_ON_ERROR;
/**
* Test fonctionnel de `app:qualimat:sync` via l'option --file (pas d'appel
* reseau) : verifie l'upsert normalise, le journal et le soft-delete.
*
* @internal
*/
final class SyncQualimatCommandTest extends KernelTestCase
{
private Connection $connection;
protected function setUp(): void
{
self::bootKernel();
/** @var Connection $connection */
$connection = self::getContainer()->get('doctrine.dbal.default_connection');
$this->connection = $connection;
$this->purge();
}
protected function tearDown(): void
{
$this->purge();
parent::tearDown();
}
public function testFirstSyncInsertsNormalizesAndLogs(): void
{
$tester = $this->runSync([
[
'Nom' => '2C TRANS',
'Societe' => '2C TRANS',
'Adresse' => '66 Impasse Mendi',
'CodePostal' => '65500',
'Ville' => 'VIC EN BIGORRE',
'Telephone_1' => '+33|0608890316',
'Siret' => '444 156 285 000 25',
'Validite' => '14/05/2027',
'Statut' => 'Audité',
'Departement' => '65 - Hautes-Pyrénées',
],
// Item sans SIRET : doit etre ignore (compte dans rows_skipped).
['Nom' => 'SANS SIRET', 'Siret' => null, 'Validite' => '01/01/2030', 'Statut' => 'Valide'],
]);
$tester->assertCommandIsSuccessful();
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier'));
$row = $this->connection->fetchAssociative('SELECT * FROM qualimat_carrier');
self::assertNotFalse($row);
self::assertSame('44415628500025', $row['siret']);
self::assertSame('2C TRANS', $row['name']);
self::assertSame('2027-05-14', $row['validity_date']);
self::assertSame('+33|0608890316', $row['phone']);
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier WHERE is_active = TRUE'));
$log = $this->connection->fetchAssociative('SELECT * FROM qualimat_sync_log ORDER BY id DESC LIMIT 1');
self::assertNotFalse($log);
self::assertSame(2, (int) $log['rows_total']);
self::assertSame(1, (int) $log['rows_upserted']);
self::assertSame(1, (int) $log['rows_skipped']);
self::assertSame(0, (int) $log['rows_deactivated']);
}
public function testSecondSyncUpdatesAndSoftDeletesMissing(): void
{
$a = ['Nom' => 'A', 'Siret' => '111 111 111 00011', 'Validite' => '01/01/2030', 'Statut' => 'Audité'];
$b = ['Nom' => 'B', 'Siret' => '222 222 222 00022', 'Validite' => '01/01/2030', 'Statut' => 'Audité'];
$this->runSync([$a, $b])->assertCommandIsSuccessful();
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier WHERE is_active = TRUE'));
// 2e run sans B et avec A renomme : A est mis a jour, B est soft-delete.
$aRenamed = ['Nom' => 'A BIS', 'Siret' => '111 111 111 00011', 'Validite' => '02/02/2031', 'Statut' => 'Valide'];
$tester = $this->runSync([$aRenamed]);
$tester->assertCommandIsSuccessful();
// Toujours 2 lignes en base, mais une seule active.
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier'));
self::assertSame(1, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier WHERE is_active = TRUE'));
self::assertSame(1, $this->countRows("SELECT COUNT(*) FROM qualimat_carrier WHERE siret = '22222222200022' AND is_active = FALSE"));
// A a bien ete mis a jour (nom + statut + date).
$a = $this->connection->fetchAssociative("SELECT * FROM qualimat_carrier WHERE siret = '11111111100011'");
self::assertNotFalse($a);
self::assertSame('A BIS', $a['name']);
self::assertSame('Valide', $a['status']);
self::assertSame('2031-02-02', $a['validity_date']);
$log = $this->connection->fetchAssociative('SELECT * FROM qualimat_sync_log ORDER BY id DESC LIMIT 1');
self::assertNotFalse($log);
self::assertSame(1, (int) $log['rows_upserted']);
self::assertSame(1, (int) $log['rows_deactivated']);
self::assertSame(0, (int) $log['rows_skipped']);
}
public function testEmptySourceAbortsWithoutMassDeactivation(): void
{
// Premier run : 2 transporteurs actifs.
$a = ['Nom' => 'A', 'Siret' => '111 111 111 00011', 'Validite' => '01/01/2030', 'Statut' => 'Audité'];
$b = ['Nom' => 'B', 'Siret' => '222 222 222 00022', 'Validite' => '01/01/2030', 'Statut' => 'Audité'];
$this->runSync([$a, $b])->assertCommandIsSuccessful();
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier WHERE is_active = TRUE'));
// Source ne contenant que des items inexploitables (zero ligne mappee) :
// la commande doit ECHOUER sans toucher le referentiel (pas de soft-delete
// de masse) et sans journaliser de run.
$logsBefore = $this->countRows('SELECT COUNT(*) FROM qualimat_sync_log');
$tester = $this->runSync([
['Nom' => 'SANS SIRET 1', 'Siret' => null],
['Nom' => 'SANS SIRET 2', 'Siret' => ' '],
]);
self::assertSame(Command::FAILURE, $tester->getStatusCode());
// Les 2 transporteurs restent ACTIFS (aucune desactivation de masse).
self::assertSame(2, $this->countRows('SELECT COUNT(*) FROM qualimat_carrier WHERE is_active = TRUE'));
// Aucun journal supplementaire (abandon avant la transaction).
self::assertSame($logsBefore, $this->countRows('SELECT COUNT(*) FROM qualimat_sync_log'));
}
/**
* @param array<int, array<string, mixed>> $items
*/
private function runSync(array $items): CommandTester
{
$path = tempnam(sys_get_temp_dir(), 'qualimat_').'.json';
file_put_contents($path, json_encode($items, JSON_THROW_ON_ERROR));
$application = new Application(self::$kernel);
$tester = new CommandTester($application->find('app:qualimat:sync'));
$tester->execute(['--file' => $path]);
@unlink($path);
return $tester;
}
private function countRows(string $sql): int
{
return (int) $this->connection->fetchOne($sql);
}
private function purge(): void
{
$this->connection->executeStatement('DELETE FROM qualimat_carrier');
$this->connection->executeStatement('DELETE FROM qualimat_sync_log');
}
}