Compare commits

..

11 Commits

Author SHA1 Message Date
tristan c8bff68373 chore(transport) : ajouter la cible make qualimat-sync (ERP-39) 2026-06-12 15:19:13 +02:00
tristan b444061237 feat(transport) : synchronisation du référentiel transporteurs QUALIMAT (ERP-39)
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 (upsert sur le SIRET + soft-delete des absents + journal). Prévue pour un cron quotidien.

- migration : tables qualimat_carrier + qualimat_sync_log (COMMENT ON COLUMN sur chaque colonne)
- QualimatRowMapper : normalisation pure (SIRET sans espaces, date dd/mm/yyyy -> ISO, skip sans SIRET) + tests unitaires
- SyncQualimatCommand : options --file / --ppp / --dry-run, upsert DBAL transactionnel
- activation de framework.http_client
- tests fonctionnels de la commande (upsert/normalisation/journal/soft-delete)
2026-06-12 15:03:28 +02:00
tristan 5f3da7022b feat(transport) : créer le module Transport (ERP-150)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m29s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m41s
Module Transport (ID transport, non requis) destiné à héberger les référentiels externes synchronisés par commandes console (codes IDTF ERP-149, transporteurs QUALIMAT ERP-39).

- TransportModule.php avec permissions() vide à ce stade
- activation dans config/modules.php
- layer Nuxt front minimal (pas d'écran ni d'item sidebar)
2026-06-12 14:35:16 +02:00
gitea-actions b36520d3b1 chore: bump version to v0.1.110
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 1m17s
2026-06-12 08:45:47 +00:00
tristan a340d8139a feat(commercial) : amélioration et validation stricte des champs date (ERP-148) (#92)
Auto Tag Develop / tag (push) Successful in 8s
## Contexte
ERP-148 — mise à jour @malio/layer-ui et amélioration des champs date (onglet Information, Client & Fournisseur).

## Changements
- **MalioDate v1.7.10** : le composant expose désormais son état de validité (`@update:valid`) et la saisie brute invalide (`@update:rawValue`).
- **Validation back-autoritaire du format** : `foundedAt` n'accepte plus que l'ISO strict `Y-m-d` (`#[Context]` DateTimeNormalizer) + `collectDenormalizationErrors` sur `Client` et `Supplier`. Toute saisie non-ISO renvoie un **422 porté sur le champ**.
  - Corrige un cas piège : `12/25/2026` (invalide en JJ/MM/AAAA côté front) était auparavant accepté par PHP en M/J/AAAA → 25 décembre. Désormais rejeté.
- **Front** : la saisie invalide est transmise au back ; le message technique de type-error est surchargé par une clé i18n via le **code de violation** (`resolveViolationMessage` / `VIOLATION_MESSAGE_I18N`), affiché inline par `useFormErrors`.
- Réorganisation des utils de formulaire sous `utils/forms/`.

## Tests
- Back : `ClientFoundedAtFormatTest` / `SupplierFoundedAtFormatTest` (dont le cas piège `12/25/2026`).
- Front : résolveur i18n (`api.test.ts`, `useFormErrors.test.ts`) + payloads (`clientEdit`/`supplierEdit` specs).
- Suite Commercial verte ; vérifié bout-en-bout en navigateur (PATCH → 422, erreur inline, submit bloqué).

## Note
Échecs JWT aléatoires connus du hook pre-commit (401/500 sur tests d'auth sans rapport) ; tous verts en isolation.

Reviewed-on: #92
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-12 08:45:38 +00:00
gitea-actions 7d8a633eee chore: bump version to v0.1.109
Auto Tag Develop / tag (push) Successful in 7s
2026-06-11 15:10:30 +00:00
tristan df9451a5f4 fix(commercial) : champ Fonction du contact sur 2 colonnes (ERP-147) (#88)
Auto Tag Develop / tag (push) Successful in 8s
ERP-147 — Le champ « Fonction » (jobTitle) du bloc contact passe sur 2 colonnes, côté Client (M1) et Fournisseur (M2).

## Changements
- `ClientContactBlock.vue` — champ Fonction enrobé dans `<div class="col-span-2">`
- `SupplierContactBlock.vue` — idem côté fournisseur

## Détail technique
Le wrapper `col-span-2` est nécessaire car `MalioInputText` (`inheritAttrs:false`) renvoie `class` sur son input interne et non sur la cellule de grille — même pattern que `ClientAddressBlock.vue`.

## Vérification
- `eslint` OK sur les 2 fichiers
- Rendu à valider visuellement sur les écrans Ajouter/Modifier client et fournisseur

Reviewed-on: #88
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-11 15:10:21 +00:00
gitea-actions cb12490ba0 chore: bump version to v0.1.108
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 43s
2026-06-11 10:05:53 +00:00
tristan a442d124a3 fix(commercial) : conserver le RIB au changement de type de règlement hors-LCR (ERP-121) (#86)
Auto Tag Develop / tag (push) Successful in 11s
## Contexte — ERP-121

Le passage d'un tiers de **LCR** vers **virement** (ou autre) supprimait ses RIB en base : au changement de type de règlement, le front marquait les `ClientRib` / `SupplierRib` existants pour suppression puis envoyait des `DELETE`. Le métier veut **conserver** le RIB (coordonnée bancaire du tiers, découplée du mode de règlement) pour un éventuel retour en LCR.

## Décisions métier (validées)

1. **Affichage hors-LCR** : RIB **totalement masqué**, ré-affiché au retour LCR — jamais supprimé en base.
2. **RGPD / IBAN** : conservation telle quelle, hors-scope de ce ticket.
3. **Données déjà perdues** : acceptable, le fix ne vaut que pour l'avenir.

## Modifications (100% frontend — clients **et** fournisseurs)

- `new.vue` / `[id]/edit.vue` : `onPaymentTypeChange` ne marque plus les RIB pour suppression et ne jette plus la saisie ; ils sont seulement masqués (`visibleRibs`) et réapparaissent au retour LCR.
- `submitAccounting` ne (re)soumet les RIB que **sous LCR** ; seules les suppressions **explicites** (corbeille d'un bloc) restent en `DELETE`.
- Consultation `[id]/index.vue` : RIB dormants masqués hors-LCR via le helper pur type-safe `paymentTypeCodeOf` (+ tests Vitest).

## Back

**Aucune modification** : la seule règle est `LCR → ≥1 RIB` (RG-1.13 / RG-2.08) ; rien n'interdit un RIB sur un tiers non-LCR. Le guard `Client/SupplierRibProcessor` (refus de supprimer le dernier RIB sous LCR) reste inchangé. **Pas de migration.**

## Vérifications

-  Vitest : **384/384** (`make nuxt-test`)
-  ESLint : clean sur les 10 fichiers
- ⏭️ PHPUnit non lancé : aucun fichier back modifié

Reviewed-on: #86
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-11 10:05:40 +00:00
gitea-actions 431d831c8b chore: bump version to v0.1.107
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 41s
2026-06-11 08:09:47 +00:00
matthieu 3f356f0679 feat(commercial) : referentiel pays (country) en base + branchement front (ERP-116) (#79)
Auto Tag Develop / tag (push) Successful in 9s
## Objectif (ERP-116, 1re iteration minimale)

Sortir la liste des pays du **code en dur** cote front et la poser en base comme **referentiel `country`**, source unique du select pays. **Perimetre volontairement minimal** : code ISO + libelle + ordre uniquement — **aucune longueur bancaire/fiscale** (numero de compte, IBAN, TVA, BIC, SIREN) a ce stade.

## Backend
- Entite `Country` (`code` ISO 3166-1 alpha-2 unique, `name`, `position`), calquee sur `Bank` : referentiel statique **lecture seule** (`GetCollection` + `Get`), gating `commercial.clients.view OR commercial.suppliers.view`.
- Migration `Version20260609100000` : table `country` + `COMMENT ON COLUMN` + seed des **6 pays** (France, Allemagne, Belgique, Espagne, Italie, Royaume-Uni), `ON CONFLICT DO NOTHING`.
- `CommercialReferentialFixtures` : re-seed des pays en dev/test.
- Garde-fous : ajout au `ColumnCommentsCatalog` + whitelist `EntitiesAreTimestampableBlamableTest`.

## Frontend
- `useClientReferentials` charge `/countries` (value = **nom** du pays : l'adresse stocke `country` en chaine libre, **pas de FK ni migration de donnees**).
- Les 3 listes `countryOptions` en dur (clients new / edit / consultation) sont supprimees ; la consultation derive ses options de l'embed.

## Hors-scope (iterations suivantes du ticket)
- Longueurs bancaires/fiscales par pays + validation associee.
- FK `country_id` sur les adresses + migration de donnees.

## Tests
- Back : suite complete verte (583), tests API dedies countries (200/seed/405/403/401).
- Front : Vitest vert (256), spec `useClientReferentials` mise a jour.
- Migration appliquee en dev + test.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #79
2026-06-11 08:09:38 +00:00
57 changed files with 1893 additions and 231 deletions
+1
View File
@@ -79,6 +79,7 @@ Regles :
- **Toujours `{ toast: false }`** sur l'appel API qui veut un mapping inline (sinon le toast natif d'`useApi` masque le fin). - **Toujours `{ toast: false }`** sur l'appel API qui veut un mapping inline (sinon le toast natif d'`useApi` masque le fin).
- **Cas metier specifique** (ex: 409 doublon) : `setError('champ', message)` + toast explicite **avant** de deleguer le reste a `handleApiError`. Cf. `useCategoryForm` (doublon RG-1.07). - **Cas metier specifique** (ex: 409 doublon) : `setError('champ', message)` + toast explicite **avant** de deleguer le reste a `handleApiError`. Cf. `useCategoryForm` (doublon RG-1.07).
- **Collections** (listes de sous-entites sauvees par un appel par ligne) : une erreur PAR LIGNE via un tableau `ref<Record<string, string>[]>` aligne sur l'index, peuple par `mapViolationsToRecord(error.response._data)` (util pur de `shared/utils/api.ts`). Le composant de ligne expose une prop `:errors` (`Record<string, string>`) bindee sur le `:error` de chaque champ. Cf. `ClientContactBlock` / `ClientAddressBlock` et les submits de `clients/new.vue` / `clients/[id]/edit.vue`. - **Collections** (listes de sous-entites sauvees par un appel par ligne) : une erreur PAR LIGNE via un tableau `ref<Record<string, string>[]>` aligne sur l'index, peuple par `mapViolationsToRecord(error.response._data)` (util pur de `shared/utils/api.ts`). Le composant de ligne expose une prop `:errors` (`Record<string, string>`) bindee sur le `:error` de chaque champ. Cf. `ClientContactBlock` / `ClientAddressBlock` et les submits de `clients/new.vue` / `clients/[id]/edit.vue`.
- **Message back technique → surcharge i18n par code** : la plupart des contraintes back portent un message FR explicite (affiche tel quel). Mais une 422 peut porter un message TECHNIQUE non montrable (ex. erreur de type API Platform sur une date non parsable : « Cette valeur doit être de type DateTimeImmutable|null. », voire en anglais selon la negociation). On le surcharge **cote front** via le `code` de violation (UUID Symfony fige, robuste — pas un match sur le texte) : table `VIOLATION_MESSAGE_I18N` + `resolveViolationMessage` dans `shared/utils/api.ts`, appliquee par `useFormErrors`. Ajouter un cas = une entree `code -> cle i18n`. Cas reference : date invalide (MalioDate forwarde la saisie brute via `@update:rawValue`, le back renvoie 422 sur `foundedAt` grace a `collectDenormalizationErrors`, le front affiche `errors.validation.invalidDate`).
**Interdit** : se contenter d'un toast global sur une 422 quand le back identifie les champs fautifs (`propertyPath`). Reimplementer un mapping `if/else` par champ a la main au lieu d'`useFormErrors` / `mapViolationsToRecord`. **Interdit** : se contenter d'un toast global sur une 422 quand le back identifie les champs fautifs (`propertyPath`). Reimplementer un mapping `if/else` par champ a la main au lieu d'`useFormErrors` / `mapViolationsToRecord`.
+2
View File
@@ -5,10 +5,12 @@ use App\Module\Catalog\CatalogModule;
use App\Module\Commercial\CommercialModule; use App\Module\Commercial\CommercialModule;
use App\Module\Core\CoreModule; use App\Module\Core\CoreModule;
use App\Module\Sites\SitesModule; use App\Module\Sites\SitesModule;
use App\Module\Transport\TransportModule;
return [ return [
CoreModule::class, CoreModule::class,
CommercialModule::class, CommercialModule::class,
SitesModule::class, SitesModule::class,
CatalogModule::class, CatalogModule::class,
TransportModule::class,
]; ];
+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: parameters:
app.version: '0.1.106' app.version: '0.1.110'
+4 -1
View File
@@ -386,7 +386,10 @@
}, },
"title": "Erreur", "title": "Erreur",
"generic": "Une erreur est survenue.", "generic": "Une erreur est survenue.",
"unknown": "Erreur inconnue." "unknown": "Erreur inconnue.",
"validation": {
"invalidDate": "Date invalide"
}
}, },
"sites": { "sites": {
"selector": { "selector": {
@@ -187,7 +187,7 @@ import {
addressTypeFromFlags, addressTypeFromFlags,
isBillingEmailRequired, isBillingEmailRequired,
type AddressType, type AddressType,
} from '~/modules/commercial/utils/clientFormRules' } from '~/modules/commercial/utils/forms/clientFormRules'
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete' import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials' import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm' import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
@@ -26,13 +26,18 @@
:error="errors?.firstName" :error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)" @update:model-value="(v: string) => update('firstName', v)"
/> />
<MalioInputText <!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
:model-value="model.jobTitle" (inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
:label="t('commercial.clients.form.contact.jobTitle')" cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
:readonly="readonly" <div class="col-span-2">
:error="errors?.jobTitle" <MalioInputText
@update:model-value="(v: string) => update('jobTitle', v)" :model-value="model.jobTitle"
/> :label="t('commercial.clients.form.contact.jobTitle')"
:readonly="readonly"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
</div>
<MalioInputEmail <MalioInputEmail
:model-value="model.email" :model-value="model.email"
:label="t('commercial.clients.form.contact.email')" :label="t('commercial.clients.form.contact.email')"
@@ -25,13 +25,18 @@
:error="errors?.firstName" :error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)" @update:model-value="(v: string) => update('firstName', v)"
/> />
<MalioInputText <!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
:model-value="model.jobTitle" (inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
:label="t('commercial.suppliers.form.contact.jobTitle')" cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
:readonly="readonly" <div class="col-span-2">
:error="errors?.jobTitle" <MalioInputText
@update:model-value="(v: string) => update('jobTitle', v)" :model-value="model.jobTitle"
/> :label="t('commercial.suppliers.form.contact.jobTitle')"
:readonly="readonly"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
</div>
<MalioInputEmail <MalioInputEmail
:model-value="model.email" :model-value="model.email"
:label="t('commercial.suppliers.form.contact.email')" :label="t('commercial.suppliers.form.contact.email')"
@@ -30,6 +30,10 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
if (url === '/sites') { if (url === '/sites') {
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] }) return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
} }
if (url === '/countries') {
// Pays : value === label === name (l'adresse stocke le nom).
return Promise.resolve({ member: [{ '@id': '/api/countries/1', code: 'FR', name: 'France' }] })
}
return Promise.resolve({ return Promise.resolve({
member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }], member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }],
}) })
@@ -44,6 +48,8 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }]) expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }]) expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }]) expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
// Pays : value = nom du pays (et non l'IRI).
expect(refs.countries.value).toEqual([{ value: 'France', label: 'France' }])
// Seul le select en echec reste vide. // Seul le select en echec reste vide.
expect(refs.categories.value).toEqual([]) expect(refs.categories.value).toEqual([])
@@ -1,5 +1,5 @@
import { ref } from 'vue' import { ref } from 'vue'
import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation' import type { ClientDetail } from '~/modules/commercial/utils/forms/clientConsultation'
/** /**
* Chargement et actions d'archivage d'un client unique (ecran « Consultation * Chargement et actions d'archivage d'un client unique (ecran « Consultation
@@ -3,7 +3,7 @@ import { ref } from 'vue'
/** /**
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran * Charge les referentiels (listes courtes) alimentant les selects de l'ecran
* « Ajouter un client » : categories, sites, modes de TVA, delais et types de * « Ajouter un client » : categories, sites, modes de TVA, delais et types de
* reglement, banques, et les listes distributeurs / courtiers. * reglement, banques, pays, et les listes distributeurs / courtiers.
* *
* Toutes les collections sont recuperees en entier via l'echappatoire prevue * Toutes les collections sont recuperees en entier via l'echappatoire prevue
* `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec * `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec
@@ -57,6 +57,11 @@ interface ClientMember extends HydraMember {
companyName: string companyName: string
} }
interface CountryMember extends HydraMember {
code: string
name: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' } const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useClientReferentials() { export function useClientReferentials() {
@@ -68,6 +73,7 @@ export function useClientReferentials() {
const paymentDelays = ref<RefOption[]>([]) const paymentDelays = ref<RefOption[]>([])
const paymentTypes = ref<PaymentTypeOption[]>([]) const paymentTypes = ref<PaymentTypeOption[]>([])
const banks = ref<RefOption[]>([]) const banks = ref<RefOption[]>([])
const countries = ref<RefOption[]>([])
const distributors = ref<ClientOption[]>([]) const distributors = ref<ClientOption[]>([])
const brokers = ref<ClientOption[]>([]) const brokers = ref<ClientOption[]>([])
@@ -116,6 +122,12 @@ export function useClientReferentials() {
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }), .then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
fetchAll<ReferentialMember>('/banks') fetchAll<ReferentialMember>('/banks')
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }), .then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }),
// Pays (ERP-116) : la valeur d'option est le NOM du pays (et non l'IRI),
// car l'adresse stocke `country` en chaine libre (« France »...). On
// conserve ainsi la compatibilite avec les adresses existantes sans FK
// ni migration de donnees a ce stade. value === label.
fetchAll<CountryMember>('/countries')
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
]) ])
} }
@@ -144,6 +156,7 @@ export function useClientReferentials() {
paymentDelays, paymentDelays,
paymentTypes, paymentTypes,
banks, banks,
countries,
distributors, distributors,
brokers, brokers,
loadCommon, loadCommon,
@@ -1,5 +1,5 @@
import { ref } from 'vue' import { ref } from 'vue'
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation' import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
/** /**
* Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation * Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation
@@ -51,6 +51,11 @@ interface ReferentialMember extends HydraMember {
label: string label: string
} }
interface CountryMember extends HydraMember {
code: string
name: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' } const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useSupplierReferentials() { export function useSupplierReferentials() {
@@ -62,6 +67,7 @@ export function useSupplierReferentials() {
const paymentDelays = ref<RefOption[]>([]) const paymentDelays = ref<RefOption[]>([])
const paymentTypes = ref<PaymentTypeOption[]>([]) const paymentTypes = ref<PaymentTypeOption[]>([])
const banks = ref<RefOption[]>([]) const banks = ref<RefOption[]>([])
const countries = ref<RefOption[]>([])
/** Recupere une collection complete (pagination desactivee) en Hydra. */ /** Recupere une collection complete (pagination desactivee) en Hydra. */
async function fetchAll<T extends HydraMember>( async function fetchAll<T extends HydraMember>(
@@ -103,6 +109,13 @@ export function useSupplierReferentials() {
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }), .then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
fetchAll<ReferentialMember>('/banks') fetchAll<ReferentialMember>('/banks')
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }), .then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }),
// Pays (ERP-116) : la valeur d'option est le NOM du pays (et non l'IRI),
// car l'adresse stocke `country` en chaine libre (« France »...). On
// conserve ainsi la compatibilite avec les adresses existantes sans FK
// ni migration de donnees a ce stade. value === label. Aligne sur les
// clients (`useClientReferentials`) pour une liste de pays identique.
fetchAll<CountryMember>('/countries')
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
]) ])
} }
@@ -113,6 +126,7 @@ export function useSupplierReferentials() {
paymentDelays, paymentDelays,
paymentTypes, paymentTypes,
banks, banks,
countries,
loadCommon, loadCommon,
} }
} }
@@ -116,6 +116,7 @@
:readonly="businessReadonly" :readonly="businessReadonly"
:editable="true" :editable="true"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/> />
<MalioInputText <MalioInputText
v-model="information.employeesCount" v-model="information.employeesCount"
@@ -401,7 +402,7 @@ import {
mapAddressToDraft, mapAddressToDraft,
mapRibToDraft, mapRibToDraft,
type ClientDetail, type ClientDetail,
} from '~/modules/commercial/utils/clientConsultation' } from '~/modules/commercial/utils/forms/clientConsultation'
import { import {
buildAccountingPayload, buildAccountingPayload,
buildAddressPayload, buildAddressPayload,
@@ -417,7 +418,7 @@ import {
type ClientEditAbilities, type ClientEditAbilities,
type InformationFormDraft, type InformationFormDraft,
type MainFormDraft, type MainFormDraft,
} from '~/modules/commercial/utils/clientEdit' } from '~/modules/commercial/utils/forms/clientEdit'
import { import {
buildClientFormTabKeys, buildClientFormTabKeys,
isAddressValid, isAddressValid,
@@ -429,7 +430,7 @@ import {
isRibComplete, isRibComplete,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
showsRelationAndTriageFields, showsRelationAndTriageFields,
} from '~/modules/commercial/utils/clientFormRules' } from '~/modules/commercial/utils/forms/clientFormRules'
import { import {
emptyAddress, emptyAddress,
emptyContact, emptyContact,
@@ -553,10 +554,21 @@ const contactOptions = computed<RefOption[]>(() =>
})), })),
) )
const countryOptions: RefOption[] = [ // Pays : referentiel `country` charge via l'API (ERP-116), en remplacement de
{ value: 'France', label: 'France' }, // l'ancienne liste codee en dur. Valeur = nom du pays (l'adresse stocke
{ value: 'Espagne', label: 'Espagne' }, // `country` en chaine libre, donc value === label). On merge la valeur deja
] // stockee sur chaque adresse (embed) — comme les autres selects de cet ecran —
// pour ne pas vider le select si `/countries` echoue (resilience ERP-102) ou si
// un pays historique n'appartient pas au referentiel.
const embedCountryOptions = computed<RefOption[]>(() =>
mergeOptions([], (client.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c)
.map(c => ({ value: c, label: c }))),
)
const countryOptions = computed<RefOption[]>(() =>
mergeOptions(referentials.countries.value, embedCountryOptions.value),
)
const relationOptions = computed<RefOption[]>(() => [ const relationOptions = computed<RefOption[]>(() => [
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') }, { value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
@@ -901,17 +913,16 @@ const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void { function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value) accounting.paymentTypeIri = value === null ? null : String(value)
if (!isBankRequired.value) accounting.bankIri = null if (!isBankRequired.value) accounting.bankIri = null
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide // ERP-121 : un RIB est une coordonnee bancaire du client, decouplee du mode de
// quand LCR est choisi, sinon on vide la liste — les RIB deja persistes sont // reglement. Au passage hors-LCR on ne SUPPRIME plus les RIB existants : ils
// marques pour suppression serveur au prochain enregistrement. // restent en base, simplement masques a l'ecran (visibleRibs = []), et
// reapparaissent tels quels si l'on repasse en LCR. Seule la corbeille d'un
// bloc (askRemoveRib) retire reellement un RIB.
if (isRibRequired.value) { if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib()) if (ribs.value.length === 0) ribs.value.push(emptyRib())
} }
else { else {
for (const rib of ribs.value) { // Hors-LCR : on nettoie seulement les erreurs inline (plus affichees).
if (rib.id != null) removedRibIds.value.push(rib.id)
}
ribs.value = []
ribErrors.value = [] ribErrors.value = []
} }
} }
@@ -940,50 +951,58 @@ function askRemoveRib(index: number): void {
/** /**
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS * Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
* PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote * PATCH des scalaires (groupe client:write:accounting, exige accounting.manage cote
* back) PUIS DELETE des RIB retires. Les RIB crees d'abord : le back valide RG-1.13 * back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le back
* (LCR => au moins un RIB persiste) sur le PATCH scalaires ; les suppressions en * valide RG-1.13 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
* dernier (le guard back n'autorise la suppression du dernier RIB qu'une fois quitte *
* LCR). Aucun champ main/information dans le payload (mode strict RG-1.28 : sinon * ERP-121 : les RIB ne sont (re)soumis QUE sous LCR — hors-LCR ce sont des
* 403 sur tout le payload). * 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).
*/ */
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return if (accountingReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors() accountingErrors.clearErrors()
try { try {
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs // 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2. // ligne, tous les blocs tentes). Le back exige >=1 RIB persiste pour valider
// une LCR a l'etape 2. Hors-LCR (ERP-121), les RIB sont des coordonnees
// dormantes : rien d'editable n'est affiche, on ne les re-soumet pas.
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable : // On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable :
// sinon (ex. l'unique RIB existant supprime, remplace par un bloc vide), on la // sinon (ex. l'unique RIB existant supprime, remplace par un bloc vide), on la
// soumet pour declencher la 422 NotBlank inline plutot que de laisser le DELETE // soumet pour declencher la 422 NotBlank inline plutot que de laisser le DELETE
// echouer en « dernier RIB d'une LCR » (message plat sans propertyPath). // echouer en « dernier RIB d'une LCR » (message plat sans propertyPath).
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r)) if (isRibRequired.value) {
const ribHasError = await submitRows( const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
ribs.value, const ribHasError = await submitRows(
ribErrors, ribs.value,
async (rib) => { ribErrors,
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank async (rib) => {
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur). // Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
const body = buildRibPayload(rib, { forUpdate: rib.id !== null }) // 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
if (rib.id === null) { const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
const created = await api.post<{ id: number }>( if (rib.id === null) {
`/clients/${clientId}/ribs`, const created = await api.post<{ id: number }>(
body, `/clients/${clientId}/ribs`,
{ headers: { Accept: 'application/ld+json' }, toast: false }, body,
) { headers: { Accept: 'application/ld+json' }, toast: false },
rib.id = created.id )
} rib.id = created.id
else { }
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false }) else {
} await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}, }
error => showError(error), },
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB error => showError(error),
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank // On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
// inline (sinon la modif serait perdue en silence avec un faux toast succes). // est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib), // inline (sinon la modif serait perdue en silence avec un faux toast succes).
) rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
if (ribHasError) return )
if (ribHasError) return
}
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs). // 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try { try {
@@ -994,8 +1013,9 @@ async function submitAccounting(): Promise<void> {
return return
} }
// 3) DELETE des RIB retires : APRES le PATCH scalaires (si on quitte LCR, le // 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
// guard back n'autorise la suppression du dernier RIB qu'une fois le type change). // 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) { for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false }) await api.delete(`/client_ribs/${id}`, {}, { toast: false })
} }
@@ -280,7 +280,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient' import { useClient } from '~/modules/commercial/composables/useClient'
import { buildClientFormTabKeys } from '~/modules/commercial/utils/clientFormRules' import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab' import { readHistoryTab } from '~/shared/utils/historyTab'
import { import {
canEditClient, canEditClient,
@@ -290,13 +290,14 @@ import {
mapAddressView, mapAddressView,
mapContactToDraft, mapContactToDraft,
mapRibToDraft, mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf, referentialOptionOf,
relationOf, relationOf,
showArchiveAction, showArchiveAction,
showRestoreAction, showRestoreAction,
type ClientDetail, type ClientDetail,
type SelectOption, type SelectOption,
} from '~/modules/commercial/utils/clientConsultation' } from '~/modules/commercial/utils/forms/clientConsultation'
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm' import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur). // Masque d'affichage (purement visuel, la donnee reste celle du serveur).
@@ -355,9 +356,16 @@ const addressViews = computed(() => {
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }] return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
}) })
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le // Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
// client n'en a pas (un RIB n'existe que pour un reglement LCR — RG-1.13). Pas // client n'en a pas. Pas de bloc vierge fantome en consultation.
// de bloc vierge fantome en consultation. // ERP-121 : un client peut desormais conserver des RIB « dormants » apres etre
const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft)) // repasse hors-LCR (on ne les supprime plus). En consultation, decision metier =
// on les masque TOTALEMENT : on n'affiche les RIB que si le type de reglement
// courant est LCR (le `code` est embarque sous client:read:accounting).
const ribs = computed(() =>
isRibRequiredForPaymentType(paymentTypeCodeOf(client.value?.paymentType))
? (client.value?.ribs ?? []).map(mapRibToDraft)
: [],
)
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view). // Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail))) const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
@@ -384,10 +392,18 @@ const relationOptions = computed<SelectOption[]>(() => [
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') }, { value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
]) ])
const countryOptions: SelectOption[] = [ // Pays (ERP-116) : options construites depuis l'EMBED des adresses (jamais via
{ value: 'France', label: 'France' }, // GET /countries, sur le meme principe que les autres selects de consultation
{ value: 'Espagne', label: 'Espagne' }, // — en 403 pour les roles metier non-admin). Valeur = nom du pays stocke tel
] // quel dans l'adresse, donc value === label ; suffit a afficher le libelle en
// lecture seule.
const countryOptions = computed<SelectOption[]>(() =>
[...new Set(
(client.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c),
)].map(c => ({ value: c, label: c })),
)
// Selects comptables : libelle issu de l'embed (option unique ou vide). // Selects comptables : libelle issu de l'embed (option unique ou vide).
const tvaModeOptions = computed(() => referentialOptionOf(client.value?.tvaMode)) const tvaModeOptions = computed(() => referentialOptionOf(client.value?.tvaMode))
@@ -111,6 +111,7 @@
:readonly="isValidated('information')" :readonly="isValidated('information')"
:editable="true" :editable="true"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/> />
<MalioInputText <MalioInputText
v-model="information.employeesCount" v-model="information.employeesCount"
@@ -401,12 +402,12 @@ import {
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
lastFillableTabKey, lastFillableTabKey,
showsRelationAndTriageFields, showsRelationAndTriageFields,
} from '~/modules/commercial/utils/clientFormRules' } from '~/modules/commercial/utils/forms/clientFormRules'
import { import {
buildAddressPayload, buildAddressPayload,
buildMainPayload, buildMainPayload,
buildRibPayload, buildRibPayload,
} from '~/modules/commercial/utils/clientEdit' } from '~/modules/commercial/utils/forms/clientEdit'
import { import {
emptyAddress, emptyAddress,
emptyContact, emptyContact,
@@ -651,6 +652,8 @@ const information = reactive({
description: null as string | null, description: null as string | null,
competitors: null as string | null, competitors: null as string | null,
foundedAt: null as string | null, foundedAt: null as string | null,
// Saisie brute invalide remontee par MalioDate (cf. foundedAtRaw, MUI-44).
foundedAtRaw: '',
employeesCount: null as string | null, employeesCount: null as string | null,
revenueAmount: null as string | null, revenueAmount: null as string | null,
profitAmount: null as string | null, profitAmount: null as string | null,
@@ -666,7 +669,8 @@ async function submitInformation(): Promise<void> {
await api.patch(`/clients/${clientId.value}`, { await api.patch(`/clients/${clientId.value}`, {
description: information.description || null, description: information.description || null,
competitors: information.competitors || null, competitors: information.competitors || null,
foundedAt: information.foundedAt || null, // Saisie invalide prioritaire -> 422 back sur foundedAt (cf. foundedAtRaw).
foundedAt: information.foundedAtRaw || information.foundedAt || null,
employeesCount: information.employeesCount ? Number(information.employeesCount) : null, employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
revenueAmount: information.revenueAmount || null, revenueAmount: information.revenueAmount || null,
profitAmount: information.profitAmount || null, profitAmount: information.profitAmount || null,
@@ -778,11 +782,17 @@ const contactOptions = computed<RefOption[]>(() =>
})), })),
) )
// Pays disponibles (France preselectionnee par defaut sur chaque adresse). // Pays disponibles : referentiel `country` charge via l'API (ERP-116), en
const countryOptions: RefOption[] = [ // remplacement de l'ancienne liste codee en dur. France reste preselectionnee
{ value: 'France', label: 'France' }, // par defaut sur chaque adresse (cf. valeur initiale du draft d'adresse) : on
{ value: 'Espagne', label: 'Espagne' }, // garantit donc sa presence en fallback si `/countries` echoue (resilience
] // ERP-102), pour ne pas afficher un select vide sur une valeur deja soumise.
const countryOptions = computed<RefOption[]>(() => {
const list = referentials.countries.value
return list.some(c => c.value === 'France')
? list
: [{ value: 'France', label: 'France' }, ...list]
})
// « + Adresse » desactive tant que la derniere adresse n'est pas valide. // « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => { const canAddAddress = computed(() => {
@@ -875,13 +885,14 @@ function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value) accounting.paymentTypeIri = value === null ? null : String(value)
// La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12). // La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12).
if (!isBankRequired.value) accounting.bankIri = null if (!isBankRequired.value) accounting.bankIri = null
// Les RIB n'ont de sens que pour une LCR (RG-1.13) : on amorce un bloc vide // ERP-121 : on ne jette plus la saisie RIB au passage hors-LCR. Les blocs sont
// quand LCR est choisi, on vide la liste sinon (pas de RIB fantome soumis). // masques (visibleRibs = []) mais conserves, et reapparaissent si l'on repasse
// en LCR. Ils ne sont persistes qu'a la validation SOUS LCR (cf. submitAccounting),
// donc une saisie abandonnee hors-LCR ne cree aucun RIB orphelin.
if (isRibRequired.value) { if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib()) if (ribs.value.length === 0) ribs.value.push(emptyRib())
} }
else { else {
ribs.value = []
ribErrors.value = [] ribErrors.value = []
} }
} }
@@ -918,36 +929,41 @@ async function submitAccounting(): Promise<void> {
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors() accountingErrors.clearErrors()
try { try {
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs // 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
// tentes). Le back exige >=1 RIB persiste pour valider une LCR a l'etape 2. // ligne, tous les blocs tentes). Le back exige >=1 RIB persiste pour valider
// une LCR a l'etape 2. Hors-LCR (ERP-121), une saisie RIB eventuellement
// restee dans le brouillon est masquee et n'est PAS persistee (pas de RIB
// orphelin sur un client en virement).
// On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable : // On ne saute une amorce neuve vide QUE s'il reste un autre RIB soumettable :
// sinon (LCR sans aucun RIB rempli) on la soumet -> 422 NotBlank inline. // sinon (LCR sans aucun RIB rempli) on la soumet -> 422 NotBlank inline.
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r)) if (isRibRequired.value) {
const ribHasError = await submitRows( const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
ribs.value, const ribHasError = await submitRows(
ribErrors, ribs.value,
async (rib) => { ribErrors,
// Payload partage avec l'edition (buildRibPayload, ERP-119). async (rib) => {
const body = buildRibPayload(rib) // Payload partage avec l'edition (buildRibPayload, ERP-119).
if (rib.id === null) { const body = buildRibPayload(rib)
const created = await api.post<{ id: number }>( if (rib.id === null) {
`/clients/${clientId.value}/ribs`, const created = await api.post<{ id: number }>(
body, `/clients/${clientId.value}/ribs`,
{ headers: { Accept: 'application/ld+json' }, toast: false }, body,
) { headers: { Accept: 'application/ld+json' }, toast: false },
rib.id = created.id )
} rib.id = created.id
else { }
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false }) else {
} await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}, }
error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }), },
// On ne saute une amorce neuve (id null) totalement vide que si un autre RIB error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
// est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank // On ne saute une amorce neuve (id null) totalement vide que si un autre RIB
// inline (sinon la modif serait perdue en silence avec un faux toast succes). // est soumettable. Un RIB existant vide est toujours soumis -> 422 NotBlank
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib), // inline (sinon la modif serait perdue en silence avec un faux toast succes).
) rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
if (ribHasError) return )
if (ribHasError) return
}
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs). // 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try { try {
@@ -77,6 +77,7 @@
:readonly="businessReadonly" :readonly="businessReadonly"
:editable="true" :editable="true"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/> />
<MalioInputText <MalioInputText
v-model="information.employeesCount" v-model="information.employeesCount"
@@ -370,7 +371,7 @@ import {
mapAddressToDraft, mapAddressToDraft,
mapRibToDraft, mapRibToDraft,
type SupplierDetail, type SupplierDetail,
} from '~/modules/commercial/utils/supplierConsultation' } from '~/modules/commercial/utils/forms/supplierConsultation'
import { import {
buildAccountingPayload, buildAccountingPayload,
buildAddressPayload, buildAddressPayload,
@@ -386,7 +387,7 @@ import {
type InformationFormDraft, type InformationFormDraft,
type MainFormDraft, type MainFormDraft,
type SupplierEditAbilities, type SupplierEditAbilities,
} from '~/modules/commercial/utils/supplierEdit' } from '~/modules/commercial/utils/forms/supplierEdit'
import { import {
buildSupplierFormTabKeys, buildSupplierFormTabKeys,
isAddressValid, isAddressValid,
@@ -396,7 +397,7 @@ import {
isRibBlank, isRibBlank,
isRibComplete, isRibComplete,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
} from '~/modules/commercial/utils/supplierFormRules' } from '~/modules/commercial/utils/forms/supplierFormRules'
import { import {
emptyAddress, emptyAddress,
emptyContact, emptyContact,
@@ -515,10 +516,19 @@ const contactOptions = computed<RefOption[]>(() =>
})), })),
) )
const countryOptions: RefOption[] = [ // Pays : referentiel `country` charge via l'API (ERP-116), aligne sur l'ecran
{ value: 'France', label: 'France' }, // client. On merge la valeur deja stockee sur chaque adresse (embed) — comme les
{ value: 'Espagne', label: 'Espagne' }, // autres selects de cet ecran — pour ne pas vider le select si `/countries`
] // echoue (resilience ERP-102) ou si un pays historique n'est plus au referentiel.
const embedCountryOptions = computed<RefOption[]>(() =>
mergeOptions([], (supplier.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c)
.map(c => ({ value: c, label: c }))),
)
const countryOptions = computed<RefOption[]>(() =>
mergeOptions(referentials.countries.value, embedCountryOptions.value),
)
// Selects comptables : referentiel UNION valeur courante de l'embed (libelle). // Selects comptables : referentiel UNION valeur courante de l'embed (libelle).
const tvaModeOptions = computed(() => mergeOptions(referentials.tvaModes.value, referentialOptionOf(supplier.value?.tvaMode))) const tvaModeOptions = computed(() => mergeOptions(referentials.tvaModes.value, referentialOptionOf(supplier.value?.tvaMode)))
@@ -792,17 +802,16 @@ const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
function onPaymentTypeChange(value: string | number | null): void { function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value) accounting.paymentTypeIri = value === null ? null : String(value)
if (!isBankRequired.value) accounting.bankIri = null if (!isBankRequired.value) accounting.bankIri = null
// Les RIB n'ont de sens que pour une LCR (RG-2.08) : on amorce un bloc vide // ERP-121 : un RIB est une coordonnee bancaire du fournisseur, decouplee du mode
// quand LCR est choisi, sinon on vide la liste — les RIB deja persistes sont // de reglement. Au passage hors-LCR on ne SUPPRIME plus les RIB existants : ils
// marques pour suppression serveur au prochain enregistrement. // restent en base, simplement masques a l'ecran (visibleRibs = []), et
// reapparaissent tels quels si l'on repasse en LCR. Seule la corbeille d'un
// bloc (askRemoveRib) retire reellement un RIB.
if (isRibRequired.value) { if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib()) if (ribs.value.length === 0) ribs.value.push(emptyRib())
} }
else { else {
for (const rib of ribs.value) { // Hors-LCR : on nettoie seulement les erreurs inline (plus affichees).
if (rib.id != null) removedRibIds.value.push(rib.id)
}
ribs.value = []
ribErrors.value = [] ribErrors.value = []
} }
} }
@@ -831,44 +840,53 @@ function askRemoveRib(index: number): void {
/** /**
* Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS * Valide l'onglet Comptabilite : POST/PATCH des RIB sur la sous-ressource PUIS
* PATCH des scalaires (groupe supplier:write:accounting, exige accounting.manage * PATCH des scalaires (groupe supplier:write:accounting, exige accounting.manage
* cote back) PUIS DELETE des RIB retires. Les RIB crees d'abord : le back valide * cote back) PUIS DELETE des RIB explicitement retires. Les RIB crees d'abord : le
* RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires. Aucun champ * back valide RG-2.08 (LCR => au moins un RIB persiste) sur le PATCH scalaires.
* main/information dans le payload (mode strict RG-2.16 : sinon 403 sur tout le payload). *
* 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).
*/ */
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || tabSubmitting.value) return if (accountingReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors() accountingErrors.clearErrors()
try { try {
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne, tous les blocs // 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
// tentes). On ne saute une amorce neuve vide QUE s'il reste un autre RIB // ligne, tous les blocs tentes). Hors-LCR (ERP-121), les RIB sont des
// coordonnees dormantes : rien d'editable n'est affiche, on ne les re-soumet
// pas. On ne saute une amorce neuve vide QUE s'il reste un autre RIB
// soumettable : sinon (ex. l'unique RIB existant supprime, remplace par un // soumettable : sinon (ex. l'unique RIB existant supprime, remplace par un
// bloc vide), on la soumet pour declencher la 422 NotBlank inline plutot que // bloc vide), on la soumet pour declencher la 422 NotBlank inline plutot que
// de laisser le DELETE echouer en « dernier RIB d'une LCR » (message plat). // de laisser le DELETE echouer en « dernier RIB d'une LCR » (message plat).
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r)) if (isRibRequired.value) {
const ribHasError = await submitRows( const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
ribs.value, const ribHasError = await submitRows(
ribErrors, ribs.value,
async (rib) => { ribErrors,
// Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank async (rib) => {
// 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur). // Edition d'un RIB existant : champ requis vide envoye en `''` (NotBlank
const body = buildRibPayload(rib, { forUpdate: rib.id !== null }) // 422) au lieu d'etre omis (sinon le PATCH garderait l'ancienne valeur).
if (rib.id === null) { const body = buildRibPayload(rib, { forUpdate: rib.id !== null })
const created = await api.post<{ id: number }>( if (rib.id === null) {
`/suppliers/${supplierId}/ribs`, const created = await api.post<{ id: number }>(
body, `/suppliers/${supplierId}/ribs`,
{ headers: { Accept: 'application/ld+json' }, toast: false }, body,
) { headers: { Accept: 'application/ld+json' }, toast: false },
rib.id = created.id )
} rib.id = created.id
else { }
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false }) else {
} await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
}, }
error => showError(error), },
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib), error => showError(error),
) rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
if (ribHasError) return )
if (ribHasError) return
}
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs). // 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try { try {
@@ -879,8 +897,9 @@ async function submitAccounting(): Promise<void> {
return return
} }
// 3) DELETE des RIB retires : APRES le PATCH scalaires (si on quitte LCR, le // 3) DELETE des RIB explicitement retires (corbeille d'un bloc) : APRES le
// guard back n'autorise la suppression du dernier RIB qu'une fois le type change). // 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) { for (const id of removedRibIds.value) {
await api.delete(`/supplier_ribs/${id}`, {}, { toast: false }) await api.delete(`/supplier_ribs/${id}`, {}, { toast: false })
} }
@@ -263,7 +263,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useSupplier } from '~/modules/commercial/composables/useSupplier' import { useSupplier } from '~/modules/commercial/composables/useSupplier'
import { buildSupplierFormTabKeys } from '~/modules/commercial/utils/supplierFormRules' import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab' import { readHistoryTab } from '~/shared/utils/historyTab'
import { import {
canEditSupplier, canEditSupplier,
@@ -274,12 +274,13 @@ import {
mapAddressView, mapAddressView,
mapContactToDraft, mapContactToDraft,
mapRibToDraft, mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf, referentialOptionOf,
showArchiveAction, showArchiveAction,
showRestoreAction, showRestoreAction,
type SelectOption, type SelectOption,
type SupplierDetail, type SupplierDetail,
} from '~/modules/commercial/utils/supplierConsultation' } from '~/modules/commercial/utils/forms/supplierConsultation'
import { emptyContact } from '~/modules/commercial/types/supplierForm' import { emptyContact } from '~/modules/commercial/types/supplierForm'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur). // Masque d'affichage (purement visuel, la donnee reste celle du serveur).
@@ -338,8 +339,15 @@ const addressViews = computed(() => {
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }] return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
}) })
// Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le // Exception au placeholder ci-dessus : on n'affiche AUCUN bloc RIB quand le
// fournisseur n'en a pas (un RIB n'existe que pour un reglement LCR — RG-2.08). // fournisseur n'en a pas. ERP-121 : un fournisseur peut desormais conserver des RIB
const ribs = computed(() => (supplier.value?.ribs ?? []).map(mapRibToDraft)) // « dormants » apres etre repasse hors-LCR (on ne les supprime plus). En consultation,
// decision metier = on les masque TOTALEMENT : on n'affiche les RIB que si le type de
// reglement courant est LCR (le `code` est embarque sous supplier:read:accounting).
const ribs = computed(() =>
isRibRequiredForPaymentType(paymentTypeCodeOf(supplier.value?.paymentType))
? (supplier.value?.ribs ?? []).map(mapRibToDraft)
: [],
)
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view). // Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
const accounting = computed(() => mapAccountingDraft(supplier.value ?? ({} as SupplierDetail))) const accounting = computed(() => mapAccountingDraft(supplier.value ?? ({} as SupplierDetail)))
@@ -361,10 +369,16 @@ const allSiteOptions = computed<SelectOption[]>(() =>
})), })),
) )
const countryOptions: SelectOption[] = [ // Pays (consultation, lecture seule) : derive des adresses du fournisseur, comme
{ value: 'France', label: 'France' }, // l'ecran client. Le referentiel `country` (ERP-116) n'est pas charge ici, l'ecran
{ value: 'Espagne', label: 'Espagne' }, // n'affiche que les valeurs deja stockees.
] const countryOptions = computed<SelectOption[]>(() =>
[...new Set(
(supplier.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c),
)].map(c => ({ value: c, label: c })),
)
// Selects comptables : libelle issu de l'embed (option unique ou vide). // Selects comptables : libelle issu de l'embed (option unique ou vide).
const tvaModeOptions = computed(() => referentialOptionOf(supplier.value?.tvaMode)) const tvaModeOptions = computed(() => referentialOptionOf(supplier.value?.tvaMode))
@@ -71,6 +71,7 @@
:readonly="isValidated('information')" :readonly="isValidated('information')"
:editable="true" :editable="true"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/> />
<MalioInputText <MalioInputText
v-model="information.employeesCount" v-model="information.employeesCount"
@@ -361,7 +362,7 @@ import {
isRibComplete, isRibComplete,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
lastFillableTabKey, lastFillableTabKey,
} from '~/modules/commercial/utils/supplierFormRules' } from '~/modules/commercial/utils/forms/supplierFormRules'
import { import {
buildAccountingPayload, buildAccountingPayload,
buildAddressPayload, buildAddressPayload,
@@ -369,7 +370,7 @@ import {
buildInformationPayload, buildInformationPayload,
buildMainPayload, buildMainPayload,
buildRibPayload, buildRibPayload,
} from '~/modules/commercial/utils/supplierEdit' } from '~/modules/commercial/utils/forms/supplierEdit'
import { import {
emptyAddress, emptyAddress,
emptyContact, emptyContact,
@@ -549,6 +550,8 @@ const information = reactive({
description: null as string | null, description: null as string | null,
competitors: null as string | null, competitors: null as string | null,
foundedAt: null as string | null, foundedAt: null as string | null,
// Saisie brute invalide remontee par MalioDate (cf. foundedAtRaw, MUI-44).
foundedAtRaw: '',
employeesCount: null as string | null, employeesCount: null as string | null,
revenueAmount: null as string | null, revenueAmount: null as string | null,
profitAmount: null as string | null, profitAmount: null as string | null,
@@ -646,11 +649,15 @@ const contactOptions = computed<RefOption[]>(() =>
})), })),
) )
// Pays disponibles (France preselectionnee par defaut sur chaque adresse). // Pays : referentiel `country` charge via l'API (ERP-116), aligne sur l'ecran
const countryOptions: RefOption[] = [ // client. France garantie en tete pour rester preselectionnable par defaut sur
{ value: 'France', label: 'France' }, // chaque adresse meme si `/countries` echoue (resilience ERP-102).
{ value: 'Espagne', label: 'Espagne' }, const countryOptions = computed<RefOption[]>(() => {
] const list = referentials.countries.value
return list.some(c => c.value === 'France')
? list
: [{ value: 'France', label: 'France' }, ...list]
})
// « + Adresse » desactive tant que la derniere adresse n'est pas valide. // « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => { const canAddAddress = computed(() => {
@@ -741,13 +748,14 @@ function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value) accounting.paymentTypeIri = value === null ? null : String(value)
// La banque n'a de sens que pour un virement : on la vide sinon (RG-2.07). // La banque n'a de sens que pour un virement : on la vide sinon (RG-2.07).
if (!isBankRequired.value) accounting.bankIri = null if (!isBankRequired.value) accounting.bankIri = null
// Les RIB n'ont de sens que pour une LCR (RG-2.08) : amorce un bloc vide quand // ERP-121 : on ne jette plus la saisie RIB au passage hors-LCR. Les blocs sont
// LCR est choisi, vide la liste sinon (pas de RIB fantome soumis). // masques (visibleRibs = []) mais conserves, et reapparaissent si l'on repasse
// en LCR. Ils ne sont persistes qu'a la validation SOUS LCR (cf. submitAccounting),
// donc une saisie abandonnee hors-LCR ne cree aucun RIB orphelin.
if (isRibRequired.value) { if (isRibRequired.value) {
if (ribs.value.length === 0) ribs.value.push(emptyRib()) if (ribs.value.length === 0) ribs.value.push(emptyRib())
} }
else { else {
ribs.value = []
ribErrors.value = [] ribErrors.value = []
} }
} }
@@ -782,31 +790,36 @@ async function submitAccounting(): Promise<void> {
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors() accountingErrors.clearErrors()
try { try {
// 1) POST/PATCH des RIB d'abord (erreurs inline par ligne). On ne saute une // 1) POST/PATCH des RIB d'abord — UNIQUEMENT sous LCR (erreurs inline par
// amorce neuve vide QUE s'il reste un autre RIB soumettable : sinon (LCR sans // ligne). Hors-LCR (ERP-121), une saisie RIB eventuellement restee dans le
// aucun RIB rempli) on la soumet pour declencher la 422 NotBlank inline. // brouillon est masquee et n'est PAS persistee (pas de RIB orphelin sur un
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r)) // fournisseur en virement). On ne saute une amorce neuve vide QUE s'il reste
const ribHasError = await submitRows( // un autre RIB soumettable : sinon (LCR sans aucun RIB rempli) on la soumet
ribs.value, // pour declencher la 422 NotBlank inline.
ribErrors, if (isRibRequired.value) {
async (rib) => { const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
const body = buildRibPayload(rib) const ribHasError = await submitRows(
if (rib.id === null) { ribs.value,
const created = await api.post<{ id: number }>( ribErrors,
`/suppliers/${supplierId.value}/ribs`, async (rib) => {
body, const body = buildRibPayload(rib)
{ headers: { Accept: 'application/ld+json' }, toast: false }, if (rib.id === null) {
) const created = await api.post<{ id: number }>(
rib.id = created.id `/suppliers/${supplierId.value}/ribs`,
} body,
else { { headers: { Accept: 'application/ld+json' }, toast: false },
await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false }) )
} rib.id = created.id
}, }
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }), else {
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib), await api.patch(`/supplier_ribs/${rib.id}`, body, { toast: false })
) }
if (ribHasError) return },
error => toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(error) }),
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
)
if (ribHasError) return
}
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs). // 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try { try {
@@ -9,6 +9,7 @@ import {
mapAddressView, mapAddressView,
mapContactToDraft, mapContactToDraft,
mapRibToDraft, mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf, referentialOptionOf,
relationOf, relationOf,
showArchiveAction, showArchiveAction,
@@ -233,3 +234,17 @@ describe('showArchiveAction / showRestoreAction', () => {
expect(showRestoreAction(can([]), true)).toBe(false) expect(showRestoreAction(can([]), true)).toBe(false)
}) })
}) })
describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', () => {
it('retourne le code metier quand le type de reglement est embarque', () => {
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1', code: 'LCR' })).toBe('LCR')
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/2', code: 'VIREMENT' })).toBe('VIREMENT')
})
it('retourne null pour un IRI nu, un objet sans code, ou une relation absente', () => {
expect(paymentTypeCodeOf('/api/payment_types/1')).toBeNull()
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1' })).toBeNull()
expect(paymentTypeCodeOf(null)).toBeNull()
expect(paymentTypeCodeOf(undefined)).toBeNull()
})
})
@@ -36,6 +36,7 @@ function informationDraft(overrides: Partial<InformationFormDraft> = {}): Inform
description: 'desc', description: 'desc',
competitors: 'concurrents', competitors: 'concurrents',
foundedAt: '2010-05-01', foundedAt: '2010-05-01',
foundedAtRaw: '',
employeesCount: '42', employeesCount: '42',
revenueAmount: '1000000', revenueAmount: '1000000',
profitAmount: '50000', profitAmount: '50000',
@@ -140,6 +141,16 @@ describe('buildInformationPayload — scoping strict groupe client:write:informa
expect(payload.description).toBeNull() expect(payload.description).toBeNull()
expect(payload.directorName).toBeNull() expect(payload.directorName).toBeNull()
}) })
it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => {
// Saisie malformee : on transmet le texte brut tel quel pour declencher la
// 422 back sur foundedAt (validation autoritaire du format, MUI-44).
expect(buildInformationPayload(informationDraft({ foundedAt: null, foundedAtRaw: '32/13/2026' })).foundedAt)
.toBe('32/13/2026')
// Saisie valide : foundedAtRaw vide -> on envoie la date ISO.
expect(buildInformationPayload(informationDraft({ foundedAt: '2010-05-01', foundedAtRaw: '' })).foundedAt)
.toBe('2010-05-01')
})
}) })
describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => { describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => {
@@ -9,6 +9,7 @@ import {
mapAddressView, mapAddressView,
mapContactToDraft, mapContactToDraft,
mapRibToDraft, mapRibToDraft,
paymentTypeCodeOf,
referentialOptionOf, referentialOptionOf,
showArchiveAction, showArchiveAction,
showRestoreAction, showRestoreAction,
@@ -222,3 +223,17 @@ describe('showArchiveAction / showRestoreAction', () => {
expect(showRestoreAction(can([]), true)).toBe(false) expect(showRestoreAction(can([]), true)).toBe(false)
}) })
}) })
describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', () => {
it('retourne le code metier quand le type de reglement est embarque', () => {
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1', code: 'LCR' })).toBe('LCR')
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/2', code: 'VIREMENT' })).toBe('VIREMENT')
})
it('retourne null pour un IRI nu, un objet sans code, ou une relation absente', () => {
expect(paymentTypeCodeOf('/api/payment_types/1')).toBeNull()
expect(paymentTypeCodeOf({ '@id': '/api/payment_types/1' })).toBeNull()
expect(paymentTypeCodeOf(null)).toBeNull()
expect(paymentTypeCodeOf(undefined)).toBeNull()
})
})
@@ -11,7 +11,7 @@ import {
mapMainDraft, mapMainDraft,
resolveTabEditability, resolveTabEditability,
} from '../supplierEdit' } from '../supplierEdit'
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation' import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm' import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm'
describe('buildMainPayload (groupe supplier:write:main)', () => { describe('buildMainPayload (groupe supplier:write:main)', () => {
@@ -37,7 +37,7 @@ describe('buildMainPayload (groupe supplier:write:main)', () => {
describe('buildInformationPayload (groupe supplier:write:information)', () => { describe('buildInformationPayload (groupe supplier:write:information)', () => {
const base = { const base = {
description: null, competitors: null, foundedAt: null, employeesCount: null, description: null, competitors: null, foundedAt: null, foundedAtRaw: '', employeesCount: null,
revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null, revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null,
} }
@@ -48,6 +48,15 @@ describe('buildInformationPayload (groupe supplier:write:information)', () => {
}) })
expect(buildInformationPayload(base)).toMatchObject({ employeesCount: null, volumeForecast: null }) expect(buildInformationPayload(base)).toMatchObject({ employeesCount: null, volumeForecast: null })
}) })
it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => {
// Saisie malformee transmise telle quelle pour declencher la 422 back (MUI-44).
expect(buildInformationPayload({ ...base, foundedAt: null, foundedAtRaw: '32/13/2026' }).foundedAt)
.toBe('32/13/2026')
// Saisie valide : foundedAtRaw vide -> on envoie la date ISO.
expect(buildInformationPayload({ ...base, foundedAt: '2008-04-01', foundedAtRaw: '' }).foundedAt)
.toBe('2008-04-01')
})
}) })
describe('buildContactPayload (sous-ressource supplier_contact)', () => { describe('buildContactPayload (sous-ressource supplier_contact)', () => {
@@ -293,6 +293,21 @@ export function referentialOptionOf(relation: Relation): SelectOption[] {
return [{ value: relation['@id'], label }] return [{ value: relation['@id'], label }]
} }
/**
* Code metier d'un referentiel embarque (ex: PaymentType.code = 'LCR' / 'VIREMENT'),
* ou null si la relation est absente / serialisee en IRI nu. Type-safe : la branche
* chaine (IRI nu) et l'absence sont court-circuitees avant l'acces au code. Sert a
* conditionner l'affichage selon le type de reglement courant (ERP-121 : RIB masques
* hors-LCR en consultation).
*/
export function paymentTypeCodeOf(relation: Relation): string | null {
if (!relation || typeof relation === 'string') {
return null
}
return (relation.code as string | undefined) ?? null
}
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */ /** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
export function mapAddressView(address: AddressRead): AddressView { export function mapAddressView(address: AddressRead): AddressView {
return { return {
@@ -20,14 +20,14 @@ import {
iriOf, iriOf,
relationOf, relationOf,
type ClientDetail, type ClientDetail,
} from '~/modules/commercial/utils/clientConsultation' } from '~/modules/commercial/utils/forms/clientConsultation'
import { import {
ADDRESS_REQUIRED_NON_NULLABLE_KEYS, ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
blankEmptyRequired, blankEmptyRequired,
MAIN_REQUIRED_NON_NULLABLE_KEYS, MAIN_REQUIRED_NON_NULLABLE_KEYS,
omitEmptyRequired, omitEmptyRequired,
RIB_REQUIRED_NON_NULLABLE_KEYS, RIB_REQUIRED_NON_NULLABLE_KEYS,
} from '~/modules/commercial/utils/clientFormRules' } from '~/modules/commercial/utils/forms/clientFormRules'
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm' import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
/** /**
@@ -53,6 +53,13 @@ export interface InformationFormDraft {
competitors: string | null competitors: string | null
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */ /** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
foundedAt: string | null foundedAt: string | null
/**
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
*/
foundedAtRaw: string
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */ /** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
employeesCount: string | null employeesCount: string | null
revenueAmount: string | null revenueAmount: string | null
@@ -118,6 +125,8 @@ export function mapInformationDraft(client: ClientDetail): InformationFormDraft
competitors: client.competitors ?? null, competitors: client.competitors ?? null,
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime. // MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null, foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null,
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
foundedAtRaw: '',
employeesCount: client.employeesCount != null ? String(client.employeesCount) : null, employeesCount: client.employeesCount != null ? String(client.employeesCount) : null,
revenueAmount: client.revenueAmount ?? null, revenueAmount: client.revenueAmount ?? null,
profitAmount: client.profitAmount ?? null, profitAmount: client.profitAmount ?? null,
@@ -191,7 +200,9 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
return { return {
description: information.description || null, description: information.description || null,
competitors: information.competitors || null, competitors: information.competitors || null,
foundedAt: information.foundedAt || null, // Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
foundedAt: information.foundedAtRaw || information.foundedAt || null,
employeesCount: information.employeesCount ? Number(information.employeesCount) : null, employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
revenueAmount: information.revenueAmount || null, revenueAmount: information.revenueAmount || null,
profitAmount: information.profitAmount || null, profitAmount: information.profitAmount || null,
@@ -268,6 +268,21 @@ export function referentialOptionOf(relation: Relation): SelectOption[] {
return [{ value: relation['@id'], label }] return [{ value: relation['@id'], label }]
} }
/**
* Code metier d'un referentiel embarque (ex: PaymentType.code = 'LCR' / 'VIREMENT'),
* ou null si la relation est absente / serialisee en IRI nu. Type-safe : la branche
* chaine (IRI nu) et l'absence sont court-circuitees avant l'acces au code. Sert a
* conditionner l'affichage selon le type de reglement courant (ERP-121 : RIB masques
* hors-LCR en consultation).
*/
export function paymentTypeCodeOf(relation: Relation): string | null {
if (!relation || typeof relation === 'string') {
return null
}
return (relation.code as string | undefined) ?? null
}
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */ /** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
export function mapAddressView(address: AddressRead): AddressView { export function mapAddressView(address: AddressRead): AddressView {
return { return {
@@ -17,8 +17,8 @@ import {
MAIN_REQUIRED_NON_NULLABLE_KEYS, MAIN_REQUIRED_NON_NULLABLE_KEYS,
omitEmptyRequired, omitEmptyRequired,
RIB_REQUIRED_NON_NULLABLE_KEYS, RIB_REQUIRED_NON_NULLABLE_KEYS,
} from '~/modules/commercial/utils/supplierFormRules' } from '~/modules/commercial/utils/forms/supplierFormRules'
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/supplierConsultation' import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
import type { import type {
SupplierAddressFormDraft, SupplierAddressFormDraft,
SupplierContactFormDraft, SupplierContactFormDraft,
@@ -38,6 +38,13 @@ export interface InformationFormDraft {
competitors: string | null competitors: string | null
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */ /** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
foundedAt: string | null foundedAt: string | null
/**
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
*/
foundedAtRaw: string
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */ /** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
employeesCount: string | null employeesCount: string | null
revenueAmount: string | null revenueAmount: string | null
@@ -95,6 +102,8 @@ export function mapInformationDraft(supplier: SupplierDetail): InformationFormDr
competitors: supplier.competitors ?? null, competitors: supplier.competitors ?? null,
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime. // MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null, foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null,
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
foundedAtRaw: '',
employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null, employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null,
revenueAmount: supplier.revenueAmount ?? null, revenueAmount: supplier.revenueAmount ?? null,
profitAmount: supplier.profitAmount ?? null, profitAmount: supplier.profitAmount ?? null,
@@ -177,7 +186,9 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
return { return {
description: information.description || null, description: information.description || null,
competitors: information.competitors || null, competitors: information.competitors || null,
foundedAt: information.foundedAt || null, // Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
foundedAt: information.foundedAtRaw || information.foundedAt || null,
employeesCount: information.employeesCount ? Number(information.employeesCount) : null, employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
revenueAmount: information.revenueAmount || null, revenueAmount: information.revenueAmount || null,
profitAmount: information.profitAmount || null, profitAmount: information.profitAmount || null,
@@ -0,0 +1 @@
export default defineNuxtConfig({})
+4 -4
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend", "name": "starseed-frontend",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.7.8", "@malio/layer-ui": "^1.7.10",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -1866,9 +1866,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@malio/layer-ui": { "node_modules/@malio/layer-ui": {
"version": "1.7.8", "version": "1.7.10",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.8/layer-ui-1.7.8.tgz", "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.10/layer-ui-1.7.10.tgz",
"integrity": "sha512-gUMAZzBsPCfQUF3OQSjN/OFzjONvQZYfwqH0u5VUbxaqwBdX1hUGtjD4ym6RvZkyNsKulrxkncFZYTWCS+IdGA==", "integrity": "sha512-ZWYaKvl+VpGAqeTE+4xdyKOmuRd4zwjlUYVppeIBZwGeNAK16kZnrztR+4eQmnzUqPZVybBhEBdKP9weqWHSUg==",
"dependencies": { "dependencies": {
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.7.8", "@malio/layer-ui": "^1.7.10",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -41,6 +41,22 @@ describe('useFormErrors', () => {
expect(hasErrors.value).toBe(true) expect(hasErrors.value).toBe(true)
}) })
it('setServerErrors surcharge un message technique (erreur de type) par la cle i18n', () => {
const { errors, setServerErrors } = useFormErrors()
const mapped = setServerErrors({
violations: [
// Code Symfony Type::INVALID_TYPE_ERROR (date non parsable) : surcharge.
{ propertyPath: 'foundedAt', message: 'Cette valeur doit être de type DateTimeImmutable|null.', code: 'ba785a8c-82cb-4283-967c-3cf342181b40' },
// Violation metier classique : message back conserve.
{ propertyPath: 'companyName', message: 'Obligatoire.', code: 'c1051bb4-d103-4f74-8988-acbcafc7fdc3' },
],
})
expect(mapped).toBe(true)
// Stub i18n -> renvoie la cle telle quelle.
expect(errors.foundedAt).toBe('errors.validation.invalidDate')
expect(errors.companyName).toBe('Obligatoire.')
})
it('setServerErrors retourne false et ne touche rien sans violation', () => { it('setServerErrors retourne false et ne touche rien sans violation', () => {
const { errors, setServerErrors } = useFormErrors() const { errors, setServerErrors } = useFormErrors()
expect(setServerErrors({})).toBe(false) expect(setServerErrors({})).toBe(false)
+10 -7
View File
@@ -17,7 +17,7 @@
* appel par ligne), utiliser directement `mapViolationsToRecord` par ligne. * appel par ligne), utiliser directement `mapViolationsToRecord` par ligne.
*/ */
import { computed, reactive } from 'vue' import { computed, reactive } from 'vue'
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api' import { extractApiErrorMessage, extractApiViolations, resolveViolationMessage } from '~/shared/utils/api'
/** /**
* Erreur HTTP capturee par ofetch. On n'expose que les champs lus ici (status * Erreur HTTP capturee par ofetch. On n'expose que les champs lus ici (status
@@ -69,13 +69,16 @@ export function useFormErrors() {
* violation exploitable). * violation exploitable).
*/ */
function setServerErrors(data: unknown): boolean { function setServerErrors(data: unknown): boolean {
const mapped = mapViolationsToRecord(data) const violations = extractApiViolations(data)
const keys = Object.keys(mapped) let mapped = false
if (keys.length === 0) return false for (const v of violations) {
for (const key of keys) { if (!v.propertyPath) continue
errors[key] = mapped[key] // Message back tel quel, sauf code surcharge par une cle i18n (ex.
// erreur de type sur une date non parsable -> « Date invalide »).
errors[v.propertyPath] = resolveViolationMessage(v, t)
mapped = true
} }
return true return mapped
} }
/** /**
+28 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { mapViolationsToRecord } from '../api' import { mapViolationsToRecord, resolveViolationMessage } from '../api'
/** /**
* Tests de `mapViolationsToRecord` fondation du mapping erreurchamp des * Tests de `mapViolationsToRecord` fondation du mapping erreurchamp des
@@ -56,3 +56,30 @@ describe('mapViolationsToRecord', () => {
expect(mapViolationsToRecord(data)).toEqual({ name: 'Second message.' }) expect(mapViolationsToRecord(data)).toEqual({ name: 'Second message.' })
}) })
}) })
/**
* Tests de `resolveViolationMessage` surcharge i18n d'un message back par code
* de violation. Le back peut renvoyer un message technique (erreur de type sur
* une date non parsable) : on le remplace via le `code` Symfony (stable) par une
* cle i18n, sans toucher au back. Le `t` ici renvoie la cle telle quelle.
*/
describe('resolveViolationMessage', () => {
const t = (key: string) => key
// Code Symfony Constraints\Type::INVALID_TYPE_ERROR (fige).
const TYPE_ERROR = 'ba785a8c-82cb-4283-967c-3cf342181b40'
it('surcharge le message technique d\'une erreur de type par la cle i18n', () => {
const v = { propertyPath: 'foundedAt', message: 'Cette valeur doit être de type DateTimeImmutable|null.', code: TYPE_ERROR }
expect(resolveViolationMessage(v, t)).toBe('errors.validation.invalidDate')
})
it('renvoie le message back tel quel quand le code n\'est pas surcharge', () => {
const v = { propertyPath: 'companyName', message: 'Le nom est obligatoire.', code: 'c1051bb4-d103-4f74-8988-acbcafc7fdc3' }
expect(resolveViolationMessage(v, t)).toBe('Le nom est obligatoire.')
})
it('renvoie le message back tel quel quand il n\'y a pas de code', () => {
const v = { propertyPath: 'siren', message: 'SIREN deja utilise.', code: '' }
expect(resolveViolationMessage(v, t)).toBe('SIREN deja utilise.')
})
})
+45 -1
View File
@@ -34,11 +34,15 @@ export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
/** /**
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath` * Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
* pointe le champ concerne, `message` est le libelle a afficher. * pointe le champ concerne, `message` est le libelle a afficher, `code` est le
* code de contrainte Symfony (UUID stable, independant de la langue) il sert
* a surcharger un message back technique par une cle i18n (cf.
* `VIOLATION_MESSAGE_I18N` / `resolveViolationMessage`).
*/ */
export interface ApiViolation { export interface ApiViolation {
propertyPath: string propertyPath: string
message: string message: string
code: string
} }
/** /**
@@ -61,6 +65,7 @@ export function extractApiViolations(data: unknown): ApiViolation[] {
out.push({ out.push({
propertyPath: String(obj.propertyPath ?? ''), propertyPath: String(obj.propertyPath ?? ''),
message: String(obj.message ?? ''), message: String(obj.message ?? ''),
code: String(obj.code ?? ''),
}) })
} }
return out return out
@@ -85,6 +90,45 @@ export function mapViolationsToRecord(data: unknown): Record<string, string> {
return out return out
} }
/**
* Surcharge i18n d'un message back par CODE de violation.
*
* La plupart des contraintes back portent deja un message FR explicite (ex.
* `#[Assert\NotBlank(message: '...')]`) : on l'affiche tel quel. Mais certaines
* 422 portent un message TECHNIQUE non montrable a l'utilisateur typiquement
* l'erreur de TYPE renvoyee par API Platform quand le back ne peut pas
* denormaliser la valeur (date non parsable envoyee sur un champ
* `DateTimeImmutable` : « Cette valeur doit être de type DateTimeImmutable|null. »,
* voire en anglais selon la negociation de langue).
*
* Plutot que de traduire/maquiller cote back, on surcharge ces messages cote
* front via leur `code` de violation. Ce code est un UUID Symfony FIGE (contrat
* de compatibilite : il ne change pas entre versions), donc bien plus robuste
* qu'un match sur le texte du message (qui depend de la langue). La table
* associe un code -> une cle i18n ; `resolveViolationMessage` l'applique.
*
* Limite a connaitre : le code de type-error est GENERIQUE (toute valeur de
* mauvais type). Dans nos formulaires, seul un champ date saisi en texte libre
* (MalioDate, qui forwarde la saisie brute invalide) le declenche, d'ou le
* libelle « Date invalide ». Si un autre champ typé en saisie libre apparait,
* affiner la resolution via `propertyPath` plutot que par code seul.
*/
export const VIOLATION_MESSAGE_I18N: Record<string, string> = {
// Symfony `Constraints\Type::INVALID_TYPE_ERROR` — valeur de mauvais type.
'ba785a8c-82cb-4283-967c-3cf342181b40': 'errors.validation.invalidDate',
}
/**
* Resout le message a afficher pour une violation : si son `code` est surcharge
* par `VIOLATION_MESSAGE_I18N`, renvoie la traduction de la cle associee ;
* sinon, le message back tel quel (cas nominal). `t` est passe par l'appelant
* (les utils sont purs, sans acces a useI18n).
*/
export function resolveViolationMessage(v: ApiViolation, t: (key: string) => string): string {
const i18nKey = VIOLATION_MESSAGE_I18N[v.code]
return i18nKey ? t(i18nKey) : v.message
}
/** /**
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON * Extrait un message d'erreur lisible depuis un payload Hydra / JSON
* d'erreur API Platform. Essaie les champs courants dans l'ordre : * d'erreur API Platform. Essaie les champs courants dans l'ordre :
+8
View File
@@ -249,6 +249,14 @@ sync-permissions:
seed-rbac: seed-rbac:
$(SYMFONY_CONSOLE) --no-interaction app: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 # Attention, supprime votre bdd local
db-reset: db-reset:
$(DOCKER_COMPOSE) down -v $(DOCKER_COMPOSE) down -v
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-116 Referentiel Pays (Country), 1re iteration : creation de la table
* `country` + seed des 7 pays (France, Allemagne, Belgique, Espagne, Italie,
* Royaume-Uni, Suisse). Devient la source unique du select pays, en
* remplacement de la liste codee en dur cote front.
*
* Perimetre minimal voulu : code ISO 3166-1 alpha-2 + libelle FR + ordre
* d'affichage UNIQUEMENT. Aucune longueur bancaire/fiscale (numero de compte,
* IBAN, TVA, BIC, SIREN) a ce stade iteration ulterieure du meme ticket.
*
* Pas de FK posee sur les adresses (client_address.country / supplier_address)
* a cette etape : ces colonnes restent des chaines libres (« France »...), donc
* aucune migration de donnees ni rupture de l'existant.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) comme les
* migrations M1/M2 du module Commercial : pas de migrations_path modulaire
* configure pour Commercial, et le tri par timestamp reste garanti.
*
* Seed idempotent `ON CONFLICT (code) DO NOTHING` : la table peut deja porter
* des donnees en prod lors d'un rejeu. Chaque colonne porte un `COMMENT ON
* COLUMN` (regle ABSOLUE n°12, garde-fou ColumnsHaveSqlCommentTest) ; la table
* est aussi mirroree dans ColumnCommentsCatalog pour survivre au
* `schema:update --force` du setup de test.
*/
final class Version20260609100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-116 : table country (referentiel pays) + seed des 7 pays.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE country (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
code VARCHAR(2) NOT NULL,
name VARCHAR(80) NOT NULL,
position INT DEFAULT 0 NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE UNIQUE INDEX uq_country_code ON country (code)');
$this->comment('country', '_table', 'Referentiel des pays selectionnables dans les adresses (clients/fournisseurs). Perimetre minimal : code ISO + libelle + ordre (pas de longueurs bancaires/fiscales).');
$this->comment('country', 'id', 'Identifiant interne auto-incremente.');
$this->comment('country', 'code', 'Code pays ISO 3166-1 alpha-2 (2 lettres MAJUSCULES, ex: FR) — unique (uq_country_code), fige a la creation.');
$this->comment('country', 'name', 'Libelle FR du pays (≤ 80 caracteres) — valeur stockee telle quelle dans les adresses (country en chaine libre a ce stade).');
$this->comment('country', 'position', 'Ordre d affichage croissant dans le selecteur pays (tri position ASC puis name ASC ; France en tete).');
// Seed initial. France en tete (position 10) puis ordre alphabetique.
// Table fraichement creee, mais ON CONFLICT pour rejouabilite en prod.
$this->addSql(<<<'SQL'
INSERT INTO country (code, name, position) VALUES
('FR', 'France', 10),
('DE', 'Allemagne', 20),
('BE', 'Belgique', 30),
('ES', 'Espagne', 40),
('IT', 'Italie', 50),
('GB', 'Royaume-Uni', 60),
('CH', 'Suisse', 70)
ON CONFLICT (code) DO NOTHING
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE country');
}
/**
* Pose un `COMMENT ON TABLE` (colonne speciale `_table`) ou
* `COMMENT ON COLUMN`. Quoting defensif des identifiants + delimiteur $_$
* pour ne pas casser sur les apostrophes des 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,
));
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
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 posee au namespace racine
* `DoctrineMigrations`, comme les autres migrations de creation de tables.
*/
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,
));
}
}
@@ -22,8 +22,10 @@ use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -94,6 +96,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
security: "is_granted('commercial.clients.manage')", security: "is_granted('commercial.clients.manage')",
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']], normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
denormalizationContext: ['groups' => ['client:write:main']], denormalizationContext: ['groups' => ['client:write:main']],
// Une valeur de mauvais type (ex. date non parsable sur foundedAt)
// doit produire un 422 porte sur le champ (violations[].propertyPath,
// mappable inline par useFormErrors) plutot qu'un 400 generique non
// exploitable. Le front (MalioDate, MUI-44) forwarde la saisie brute
// invalide : le back reste la couche autoritaire du format (ERP-101).
collectDenormalizationErrors: true,
processor: ClientProcessor::class, processor: ClientProcessor::class,
), ),
new Patch( new Patch(
@@ -117,6 +125,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
'client:write:accounting', 'client:write:accounting',
'client:write:archive', 'client:write:archive',
]], ]],
// Cf. Post : date non parsable (foundedAt) -> 422 porte sur le champ
// au lieu d'un 400 generique. Indispensable au mapping inline du
// front (MalioDate MUI-44 forwarde la saisie brute invalide).
collectDenormalizationErrors: true,
provider: ClientProvider::class, provider: ClientProvider::class,
processor: ClientProcessor::class, processor: ClientProcessor::class,
), ),
@@ -206,6 +218,13 @@ class Client implements TimestampableInterface, BlamableInterface
#[ORM\Column(type: 'date_immutable', nullable: true)] #[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['client:read', 'client:write:information'])] #[Groups(['client:read', 'client:write:information'])]
// Format d'ENTREE strict ISO `Y-m-d` (le `!` remet l'heure a 00:00:00). Sans
// ce format, PHP DateTime accepte des formes ambigues : « 12/25/2026 » (que
// le front MalioDate juge invalide en JJ/MM/AAAA) serait sinon interprete en
// M/J/AAAA -> 25 decembre 2026, et accepte a tort. Avec le format, toute
// saisie brute non-ISO (forwardee par MalioDate sur date invalide) echoue la
// denormalisation -> 422 sur foundedAt (cf. collectDenormalizationErrors).
#[Context(denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
private ?DateTimeImmutable $foundedAt = null; private ?DateTimeImmutable $foundedAt = null;
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineCountryRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Pays selectionnable dans les adresses (clients / fournisseurs) : referentiel
* statique seede par la migration (France, Allemagne, Belgique, Espagne, Italie,
* Royaume-Uni). Remplace la liste de pays jusqu'ici codee en dur cote front.
*
* Perimetre minimal (ticket ERP-116, 1re iteration) : code ISO + libelle + ordre
* d'affichage uniquement. AUCUNE longueur bancaire/fiscale (numero de compte,
* IBAN, TVA, BIC, SIREN) a ce stade ces colonnes feront l'objet d'une iteration
* ulterieure du meme ticket.
*
* Lecture seule : GetCollection + Get uniquement ; POST/PATCH/DELETE -> 405.
* Permission alignee sur Bank (referentiel d'adresse partage clients/fournisseurs).
* Pas de Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme Bank).
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['country:read']],
// Tri par defaut : position ASC (France en tete) puis name ASC.
order: ['position' => 'ASC', 'name' => 'ASC'],
// Toggle ?pagination=false pour alimenter le select (cf. Bank).
paginationClientEnabled: true,
),
new Get(
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['country:read']],
),
],
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
)]
#[ORM\Entity(repositoryClass: DoctrineCountryRepository::class)]
#[ORM\Table(name: 'country')]
#[ORM\UniqueConstraint(name: 'uq_country_code', columns: ['code'])]
class Country
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['country:read'])]
private ?int $id = null;
#[ORM\Column(length: 2)]
#[Groups(['country:read'])]
private ?string $code = null;
#[ORM\Column(length: 80)]
#[Groups(['country:read'])]
private ?string $name = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['country:read'])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -22,8 +22,10 @@ use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -94,6 +96,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
security: "is_granted('commercial.suppliers.manage')", security: "is_granted('commercial.suppliers.manage')",
normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']], normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']],
denormalizationContext: ['groups' => ['supplier:write:main']], denormalizationContext: ['groups' => ['supplier:write:main']],
// Une valeur de mauvais type (ex. date non parsable sur foundedAt)
// doit produire un 422 porte sur le champ (violations[].propertyPath,
// mappable inline par useFormErrors) plutot qu'un 400 generique. Le
// front (MalioDate, MUI-44) forwarde la saisie brute invalide : le
// back reste la couche autoritaire du format (ERP-101). Cf. Client.
collectDenormalizationErrors: true,
processor: SupplierProcessor::class, processor: SupplierProcessor::class,
), ),
new Patch( new Patch(
@@ -113,6 +121,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
'supplier:write:accounting', 'supplier:write:accounting',
'supplier:write:archive', 'supplier:write:archive',
]], ]],
// Cf. Post : date non parsable (foundedAt) -> 422 porte sur le champ
// au lieu d'un 400 generique. Indispensable au mapping inline du
// front (MalioDate MUI-44 forwarde la saisie brute invalide).
collectDenormalizationErrors: true,
provider: SupplierProvider::class, provider: SupplierProvider::class,
processor: SupplierProcessor::class, processor: SupplierProcessor::class,
), ),
@@ -187,6 +199,11 @@ class Supplier implements TimestampableInterface, BlamableInterface
#[ORM\Column(type: 'date_immutable', nullable: true)] #[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['supplier:read', 'supplier:write:information'])] #[Groups(['supplier:read', 'supplier:write:information'])]
// Format d'ENTREE strict ISO `Y-m-d` : sans lui, PHP DateTime accepte des
// formes ambigues (« 12/25/2026 », jugee invalide par MalioDate en JJ/MM/AAAA,
// serait lue en M/J -> 25 decembre et acceptee a tort). Avec le format, toute
// saisie brute non-ISO echoue -> 422 sur foundedAt. Cf. Client.
#[Context(denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
private ?DateTimeImmutable $foundedAt = null; private ?DateTimeImmutable $foundedAt = null;
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\Country;
interface CountryRepositoryInterface
{
public function findById(int $id): ?Country;
/**
* Retourne tous les pays tries position ASC puis name ASC.
*
* @return list<Country>
*/
public function findAllOrdered(): array;
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\DataFixtures; namespace App\Module\Commercial\Infrastructure\DataFixtures;
use App\Module\Commercial\Domain\Entity\Bank; use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\Country;
use App\Module\Commercial\Domain\Entity\PaymentDelay; use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType; use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode; use App\Module\Commercial\Domain\Entity\TvaMode;
@@ -14,10 +15,11 @@ use Doctrine\Persistence\ObjectManager;
/** /**
* Fixtures du module Commercial : re-seed des 4 referentiels comptables * Fixtures du module Commercial : re-seed des 4 referentiels comptables
* (tva_mode, payment_delay, payment_type, bank) seedes par la migration M1 * (tva_mode, payment_delay, payment_type, bank) seedes par la migration M1
* (Version20260601000000). * (Version20260601000000) + du referentiel pays (country) seede par la
* migration ERP-116 (Version20260609100000).
* *
* Pourquoi cette fixture EN PLUS du seed de la migration : depuis ERP-54 ces * Pourquoi cette fixture EN PLUS du seed de la migration : ces tables sont des
* 4 tables sont des entites managees par l'ORM, donc le purger Doctrine les * entites managees par l'ORM, donc le purger Doctrine les
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les * vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les
* referentiels seedes par la migration disparaitraient apres `make db-reset` * referentiels seedes par la migration disparaitraient apres `make db-reset`
* (0 ligne en dev/test) cassant les FK Client -> referentiels et les tests * (0 ligne en dev/test) cassant les FK Client -> referentiels et les tests
@@ -59,15 +61,54 @@ class CommercialReferentialFixtures extends Fixture
], ],
]; ];
/**
* Referentiel pays (ERP-116) : code ISO alpha-2 => [name, position].
* Doit rester aligne sur le seed de la migration Version20260609100000.
* Traite a part car Country porte `name` (et non `label`).
*
* @var array<string, array{string, int}>
*/
private const COUNTRIES = [
'FR' => ['France', 10],
'DE' => ['Allemagne', 20],
'BE' => ['Belgique', 30],
'ES' => ['Espagne', 40],
'IT' => ['Italie', 50],
'GB' => ['Royaume-Uni', 60],
'CH' => ['Suisse', 70],
];
public function load(ObjectManager $manager): void public function load(ObjectManager $manager): void
{ {
foreach (self::REFERENTIALS as $entityClass => $rows) { foreach (self::REFERENTIALS as $entityClass => $rows) {
$this->seedReferential($manager, $entityClass, $rows); $this->seedReferential($manager, $entityClass, $rows);
} }
$this->seedCountries($manager);
$manager->flush(); $manager->flush();
} }
/**
* Upsert idempotent du referentiel pays (lookup par code). Distinct de
* seedReferential car Country utilise setName au lieu de setLabel.
*/
private function seedCountries(ObjectManager $manager): void
{
$existingByCode = [];
foreach ($manager->getRepository(Country::class)->findAll() as $country) {
$existingByCode[$country->getCode()] = $country;
}
foreach (self::COUNTRIES as $code => [$name, $position]) {
$country = $existingByCode[$code] ?? new Country();
$country->setCode($code);
$country->setName($name);
$country->setPosition($position);
$manager->persist($country);
}
}
/** /**
* Upsert idempotent d'un referentiel : indexe l'existant par code puis * Upsert idempotent d'un referentiel : indexe l'existant par code puis
* cree/met a jour chaque entree. Les 4 entites partagent le meme contrat * cree/met a jour chaque entree. Les 4 entites partagent le meme contrat
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\Doctrine;
use App\Module\Commercial\Domain\Entity\Country;
use App\Module\Commercial\Domain\Repository\CountryRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Country>
*/
class DoctrineCountryRepository extends ServiceEntityRepository implements CountryRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Country::class);
}
public function findById(int $id): ?Country
{
return $this->find($id);
}
public function findAllOrdered(): array
{
return $this->createQueryBuilder('c')
->orderBy('c.position', 'ASC')
->addOrderBy('c.name', 'ASC')
->getQuery()
->getResult()
;
}
}
@@ -0,0 +1,124 @@
<?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).
*
* @param array<int, array<string, mixed>> $items
*
* @return array{rows: list<array<string, mixed>>, skipped: int}
*/
public static function mapMany(array $items): array
{
$rows = [];
$skipped = 0;
foreach ($items as $item) {
$row = self::mapOne($item);
if (null === $row) {
++$skipped;
continue;
}
$rows[] = $row;
}
return ['rows' => $rows, '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,250 @@
<?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;
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');
// 1. Recuperation des items (fichier local ou API).
try {
$items = null !== $file ? $this->readLocal((string) $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).
['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;
}
// 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;
}
/**
* 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();
return array_is_list($data) ? $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). Marque
* is_active=TRUE et tamponne last_synced_at avec le run courant.
*
* @param list<array<string, mixed>> $rows
*/
private function upsertAll(array $rows, string $run): int
{
$sql = <<<'SQL'
INSERT INTO qualimat_carrier
(siret, name, address, postal_code, city, phone, department, status, validity_date, is_active, last_synced_at)
VALUES
(:siret, :name, :address, :postal_code, :city, :phone, :department, :status, :validity_date, TRUE, :run)
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;
$count = 0;
foreach ($rows as $r) {
$this->connection->executeStatement($sql, [
'siret' => $r['siret'],
'name' => $r['name'],
'address' => $r['address'],
'postal_code' => $r['postal_code'],
'city' => $r['city'],
'phone' => $r['phone'],
'department' => $r['department'],
'status' => $r['status'],
'validity_date' => $r['validity_date'],
'run' => $run,
]);
++$count;
}
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)),
);
}
}
+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 [];
}
}
@@ -170,6 +170,14 @@ final class ColumnCommentsCatalog
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).', 'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
], ],
'country' => [
'_table' => 'Referentiel des pays selectionnables dans les adresses (clients/fournisseurs). Perimetre minimal : code ISO + libelle + ordre (pas de longueurs bancaires/fiscales).',
'id' => 'Identifiant interne auto-incremente.',
'code' => 'Code pays ISO 3166-1 alpha-2 (2 lettres MAJUSCULES, ex: FR) — unique (uq_country_code), fige a la creation.',
'name' => 'Libelle FR du pays (≤ 80 caracteres) — valeur stockee telle quelle dans les adresses (country en chaine libre a ce stade).',
'position' => 'Ordre d affichage croissant dans le selecteur pays (tri position ASC puis name ASC ; France en tete).',
],
'client' => [ 'client' => [
'_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).', '_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).',
'id' => 'Identifiant interne auto-incremente.', 'id' => 'Identifiant interne auto-incremente.',
@@ -6,6 +6,7 @@ namespace App\Tests\Architecture;
use App\Module\Catalog\Domain\Entity\CategoryType; use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Commercial\Domain\Entity\Bank; use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\Country;
use App\Module\Commercial\Domain\Entity\PaymentDelay; use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType; use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode; use App\Module\Commercial\Domain\Entity\TvaMode;
@@ -58,6 +59,8 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
* CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de * CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de
* tracabilite user-driven, meme justification que CategoryType. Cf. * tracabilite user-driven, meme justification que CategoryType. Cf.
* spec-back M1 § 2.6 + § 3.5. * spec-back M1 § 2.6 + § 3.5.
* - Country (ERP-116) : referentiel statique des pays (id/code/name/position),
* seede par migration, lecture seule. Meme justification que Bank.
* *
* Les futurs referentiels statiques s'ajoutent ici avec une justification. * Les futurs referentiels statiques s'ajoutent ici avec une justification.
*/ */
@@ -71,6 +74,7 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
PaymentDelay::class, PaymentDelay::class,
PaymentType::class, PaymentType::class,
Bank::class, Bank::class,
Country::class,
]; ];
public function testAllBusinessEntitiesImplementBothInterfaces(): void public function testAllBusinessEntitiesImplementBothInterfaces(): void
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Validation back-autoritative du FORMAT de la date de creation (foundedAt,
* onglet Information).
*
* Le front (MalioDate, cf. MUI-44) forwarde desormais la saisie brute invalide
* au serveur plutot que de l'avaler. Cote back, une date non parsable doit
* produire un 422 porte sur `foundedAt` (mappable inline par useFormErrors),
* et non un 400 generique. Repose sur `collectDenormalizationErrors` actif sur
* l'operation Patch du Client.
*
* @internal
*/
final class ClientFoundedAtFormatTest extends AbstractCommercialApiTestCase
{
private const string MERGE = 'application/merge-patch+json';
/** Date non parsable -> 422 porte sur foundedAt (et pas un 400 generique). */
public function testFoundedAtNonParsableEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Founded Format SARL');
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '32/13/2026'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
}
/**
* Cas piege : « 12/25/2026 » est invalide cote front (JJ/MM/AAAA -> mois 25)
* mais PHP DateTime l'accepterait en M/J/AAAA (25 decembre). Le format d'entree
* strict ISO `Y-m-d` (Context sur foundedAt) doit le rejeter -> 422.
*/
public function testFoundedAtFormatAmbiguUsEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Founded Ambigu SARL');
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '12/25/2026'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
}
/** Non-regression : une date ISO valide reste acceptee (200). */
public function testFoundedAtIsoValideEst200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Founded Ok SARL');
$data = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '2010-05-01'],
])->toArray();
self::assertResponseStatusCodeSame(200);
self::assertStringStartsWith('2010-05-01', $data['foundedAt']);
}
}
@@ -241,6 +241,72 @@ final class ReferentialApiTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(401); self::assertResponseStatusCodeSame(401);
} }
/**
* Referentiel pays (ERP-116) teste a part des 4 referentiels comptables
* car il expose `name` (et non `label`). Memes garanties : 200 + seed des 7
* pays, France en tete (position ASC), lecture seule (405), gating (403/401).
*/
public function testCountriesCollectionReturns200WithSeed(): void
{
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/countries?pagination=false', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
$members = $response->toArray()['member'];
$codes = array_map(static fn (array $m): string => $m['code'], $members);
foreach (['FR', 'DE', 'BE', 'ES', 'IT', 'GB', 'CH'] as $expected) {
self::assertContains($expected, $codes, '/api/countries doit exposer le pays seede '.$expected);
}
// Le DTO de lecture expose id / code / name / position.
$first = $members[0];
self::assertArrayHasKey('id', $first);
self::assertArrayHasKey('name', $first);
self::assertArrayHasKey('position', $first);
// Tri par defaut position ASC : France (position 10) en tete.
self::assertSame('FR', $first['code'], 'France (FR) doit etre en tete (position 10, tri position ASC).');
}
public function testCountriesGetItemReturns200(): void
{
$client = $this->createAdminClient();
$first = $client->request('GET', '/api/countries?pagination=false', ['headers' => ['Accept' => self::LD]])
->toArray()['member'][0]
;
$client->request('GET', '/api/countries/'.$first['id'], ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
}
public function testCountriesPostReturns405(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/countries', [
'headers' => ['Content-Type' => self::LD],
'json' => ['code' => 'XX', 'name' => 'Pays X', 'position' => 1],
]);
self::assertResponseStatusCodeSame(405);
}
public function testCountriesForbiddenWithoutPermission(): void
{
$creds = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$client->request('GET', '/api/countries', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
}
public function testCountriesUnauthorizedWhenAnonymous(): void
{
$client = self::createClient();
$client->request('GET', '/api/countries', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(401);
}
/** /**
* @return iterable<string, array{string, list<string>}> * @return iterable<string, array{string, list<string>}>
*/ */
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Validation back-autoritative du FORMAT de la date de creation (foundedAt,
* onglet Information) du fournisseur. Miroir de {@see ClientFoundedAtFormatTest}.
*
* Une date non parsable (saisie brute forwardee par MalioDate, MUI-44) doit
* produire un 422 porte sur `foundedAt` (mappable inline par useFormErrors), et
* non un 400 generique. Repose sur `collectDenormalizationErrors` sur les
* operations write du Supplier.
*
* @internal
*/
final class SupplierFoundedAtFormatTest extends AbstractSupplierApiTestCase
{
/** Date non parsable -> 422 porte sur foundedAt (et pas un 400 generique). */
public function testFoundedAtNonParsableEst422(): void
{
$seed = $this->seedSupplier('Founded Format Negoce');
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '32/13/2026'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
}
/**
* Cas piege : « 12/25/2026 » est invalide cote front (JJ/MM/AAAA -> mois 25)
* mais PHP DateTime l'accepterait en M/J/AAAA. Le format d'entree strict ISO
* `Y-m-d` (Context sur foundedAt) doit le rejeter -> 422.
*/
public function testFoundedAtFormatAmbiguUsEst422(): void
{
$seed = $this->seedSupplier('Founded Ambigu Negoce');
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '12/25/2026'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
}
/** Non-regression : une date ISO valide reste acceptee (200). */
public function testFoundedAtIsoValideEst200(): void
{
$seed = $this->seedSupplier('Founded Ok Negoce');
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$data = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '2010-05-01'],
])->toArray();
self::assertResponseStatusCodeSame(200);
self::assertStringStartsWith('2010-05-01', $data['foundedAt']);
}
}
@@ -0,0 +1,90 @@
<?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 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,138 @@
<?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\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']);
}
/**
* @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');
}
}